Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1156960b9 | |||
| 5539ec8542 | |||
| 73e54e252d | |||
| 70d959bd9b | |||
| 0c5b796e2e | |||
| 47dc9d865f | |||
| 4f757e3c0c | |||
| 2f0ee4c961 | |||
| 0859d47f75 | |||
| 7ea8358c06 | |||
| a5944bbe5d | |||
| 04bce3ff9f | |||
| 9572045787 | |||
| 7e1af37eb1 | |||
| 05009d7370 | |||
| f4dc11bae4 | |||
| c3b466e13d | |||
| 792e3f9445 | |||
| ae281d06bb | |||
| 3ca2799c90 | |||
| 459a88b3e7 | |||
| 437ab65fc1 | |||
| 679562e5ed | |||
| dbf550da8b | |||
| 3965a7741e | |||
| abb2cfb84b | |||
| 4e0d8ccfed | |||
| a935aa8b7c | |||
| 9912389fa1 | |||
| f1129b969d | |||
| c51b6f9ce4 | |||
| e39972357b | |||
| 9ad17e2964 | |||
| ef0a883a81 | |||
| 62ba5e9487 | |||
| 136614be94 | |||
| a912bffad5 |
@@ -147,3 +147,8 @@ generated-scratch/
|
|||||||
|
|
||||||
# Keep empty directories with .gitkeep files when needed
|
# Keep empty directories with .gitkeep files when needed
|
||||||
!.gitkeep
|
!.gitkeep
|
||||||
|
|
||||||
|
# Documentation review artifacts (CommentChecker output)
|
||||||
|
*-docs-issues.md
|
||||||
|
*-docs-fixed.md
|
||||||
|
*-docs-final.md
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ When source code changes, build and test the affected component before reporting
|
|||||||
## Design Sources To Consult Before Non-Trivial Changes
|
## Design Sources To Consult Before Non-Trivial Changes
|
||||||
|
|
||||||
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
||||||
- `glauth.md` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=lmxopcua,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
|
- `glauth.md` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=zb,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
|
||||||
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
||||||
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
||||||
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ internal sealed class CliArguments
|
|||||||
|
|
||||||
/// <summary>Returns whether the named flag was present in the arguments.</summary>
|
/// <summary>Returns whether the named flag was present in the arguments.</summary>
|
||||||
/// <param name="name">The flag name (without '--' prefix).</param>
|
/// <param name="name">The flag name (without '--' prefix).</param>
|
||||||
|
/// <returns>True if the flag was present; otherwise false.</returns>
|
||||||
public bool HasFlag(string name)
|
public bool HasFlag(string name)
|
||||||
{
|
{
|
||||||
return _flags.Contains(name);
|
return _flags.Contains(name);
|
||||||
@@ -51,6 +52,7 @@ internal sealed class CliArguments
|
|||||||
|
|
||||||
/// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary>
|
/// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
|
/// <returns>The argument value, or null if the argument was not provided.</returns>
|
||||||
public string? GetOptional(string name)
|
public string? GetOptional(string name)
|
||||||
{
|
{
|
||||||
return _values.TryGetValue(name, out string? value)
|
return _values.TryGetValue(name, out string? value)
|
||||||
@@ -60,6 +62,7 @@ internal sealed class CliArguments
|
|||||||
|
|
||||||
/// <summary>Returns the value for a required named argument, or throws if absent.</summary>
|
/// <summary>Returns the value for a required named argument, or throws if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
|
/// <returns>The argument value.</returns>
|
||||||
public string GetRequired(string name)
|
public string GetRequired(string name)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
@@ -74,6 +77,7 @@ internal sealed class CliArguments
|
|||||||
/// <summary>Parses and returns an int32 argument, or the default value if absent.</summary>
|
/// <summary>Parses and returns an int32 argument, or the default value if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param>
|
/// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param>
|
||||||
|
/// <returns>The parsed int32 value, or the default if absent.</returns>
|
||||||
public int GetInt32(string name, int? defaultValue = null)
|
public int GetInt32(string name, int? defaultValue = null)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
@@ -93,6 +97,7 @@ internal sealed class CliArguments
|
|||||||
/// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary>
|
/// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||||
|
/// <returns>The parsed uint32 value, or the default if absent.</returns>
|
||||||
public uint GetUInt32(string name, uint defaultValue)
|
public uint GetUInt32(string name, uint defaultValue)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
@@ -104,6 +109,7 @@ internal sealed class CliArguments
|
|||||||
/// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary>
|
/// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||||
|
/// <returns>The parsed uint64 value, or the default if absent.</returns>
|
||||||
public ulong GetUInt64(string name, ulong defaultValue)
|
public ulong GetUInt64(string name, ulong defaultValue)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
@@ -115,6 +121,7 @@ internal sealed class CliArguments
|
|||||||
/// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary>
|
/// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||||
|
/// <returns>The parsed TimeSpan value, or the default if absent.</returns>
|
||||||
public TimeSpan GetDuration(string name, TimeSpan defaultValue)
|
public TimeSpan GetDuration(string name, TimeSpan defaultValue)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
|
|||||||
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
|
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Disposes the galaxy client (if created) and the underlying gateway client.</summary>
|
||||||
|
/// <returns>A value task that completes when both clients are disposed.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_galaxyClient.IsValueCreated)
|
if (_galaxyClient.IsValueCreated)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ internal static class MxGatewayCliSecretRedactor
|
|||||||
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary>
|
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary>
|
||||||
/// <param name="value">The message text to redact.</param>
|
/// <param name="value">The message text to redact.</param>
|
||||||
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
|
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
|
||||||
|
/// <returns>The message text with any API key occurrence replaced by <c>[redacted]</c>.</returns>
|
||||||
public static string Redact(string value, string? apiKey)
|
public static string Redact(string value, string? apiKey)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
|
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public static class MxGatewayClientCli
|
|||||||
/// <param name="args">Command-line arguments (command name followed by options).</param>
|
/// <param name="args">Command-line arguments (command name followed by options).</param>
|
||||||
/// <param name="standardOutput">TextWriter for command output.</param>
|
/// <param name="standardOutput">TextWriter for command output.</param>
|
||||||
/// <param name="standardError">TextWriter for error messages.</param>
|
/// <param name="standardError">TextWriter for error messages.</param>
|
||||||
|
/// <returns>The process exit code (0 for success, 1 for error).</returns>
|
||||||
public static int Run(
|
public static int Run(
|
||||||
string[] args,
|
string[] args,
|
||||||
TextWriter standardOutput,
|
TextWriter standardOutput,
|
||||||
@@ -38,6 +39,7 @@ public static class MxGatewayClientCli
|
|||||||
/// <param name="standardError">TextWriter for error messages.</param>
|
/// <param name="standardError">TextWriter for error messages.</param>
|
||||||
/// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param>
|
/// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param>
|
||||||
/// <param name="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param>
|
/// <param name="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param>
|
||||||
|
/// <returns>A task that resolves to the process exit code (0 for success, 1 for error).</returns>
|
||||||
public static Task<int> RunAsync(
|
public static Task<int> RunAsync(
|
||||||
string[] args,
|
string[] args,
|
||||||
TextWriter standardOutput,
|
TextWriter standardOutput,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public sealed class BrowseChildrenSmokeTests
|
|||||||
/// Verifies that BrowseChildren returns a non-zero cache sequence and
|
/// Verifies that BrowseChildren returns a non-zero cache sequence and
|
||||||
/// a consistent children/child-has-children count from a live gateway.
|
/// a consistent children/child-has-children count from a live gateway.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
|
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
|
||||||
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
|
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,14 +8,10 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
|
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets the gateway client options.
|
|
||||||
/// </summary>
|
|
||||||
public MxGatewayClientOptions Options { get; } = options;
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets the raw gRPC client; always null for the fake.
|
|
||||||
/// </summary>
|
|
||||||
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
|
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -66,11 +62,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
|
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the request and either throws a queued exception or returns the configured reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The TestConnectionRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<TestConnectionReply> TestConnectionAsync(
|
public Task<TestConnectionReply> TestConnectionAsync(
|
||||||
TestConnectionRequest request,
|
TestConnectionRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -84,11 +76,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
return Task.FromResult(TestConnectionReply);
|
return Task.FromResult(TestConnectionReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the request and either throws a queued exception or returns the configured reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The GetLastDeployTimeRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
||||||
GetLastDeployTimeRequest request,
|
GetLastDeployTimeRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -102,11 +90,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
return Task.FromResult(GetLastDeployTimeReply);
|
return Task.FromResult(GetLastDeployTimeReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the request and either throws a queued exception or returns the configured reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The DiscoverHierarchyRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -135,11 +119,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
/// <summary>Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order.</summary>
|
/// <summary>Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order.</summary>
|
||||||
public Queue<Exception> BrowseChildrenExceptions { get; } = new();
|
public Queue<Exception> BrowseChildrenExceptions { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the request and either throws a queued exception or returns the configured reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The BrowseChildrenRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<BrowseChildrenReply> BrowseChildrenAsync(
|
public Task<BrowseChildrenReply> BrowseChildrenAsync(
|
||||||
BrowseChildrenRequest request,
|
BrowseChildrenRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -177,11 +157,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
|
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the request and streams events, checking for queued exceptions and calling WatchDeployEventsBeforeYield before each event.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The WatchDeployEventsRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
WatchDeployEventsRequest request,
|
WatchDeployEventsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
|
|||||||
@@ -11,14 +11,10 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
private readonly Queue<MxCommandReply> _invokeReplies = new();
|
private readonly Queue<MxCommandReply> _invokeReplies = new();
|
||||||
private readonly List<MxEvent> _events = [];
|
private readonly List<MxEvent> _events = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets the gateway client options.
|
|
||||||
/// </summary>
|
|
||||||
public MxGatewayClientOptions Options { get; } = options;
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets null, since this is a test fake without a real gRPC client.
|
|
||||||
/// </summary>
|
|
||||||
public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
|
public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -102,11 +98,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Queue<Exception> InvokeExceptions { get; } = new();
|
public Queue<Exception> InvokeExceptions { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Verifies that the OpenSessionAsync call is recorded and returns the configured reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The OpenSessionRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<OpenSessionReply> OpenSessionAsync(
|
public Task<OpenSessionReply> OpenSessionAsync(
|
||||||
OpenSessionRequest request,
|
OpenSessionRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -120,11 +112,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
return Task.FromResult(OpenSessionReply);
|
return Task.FromResult(OpenSessionReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Verifies that the CloseSessionAsync call is recorded and returns the configured reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The CloseSessionRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<CloseSessionReply> CloseSessionAsync(
|
public Task<CloseSessionReply> CloseSessionAsync(
|
||||||
CloseSessionRequest request,
|
CloseSessionRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -138,11 +126,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
return Task.FromResult(CloseSessionReply);
|
return Task.FromResult(CloseSessionReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Verifies that the InvokeAsync call is recorded and returns the next enqueued reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The MxCommandRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<MxCommandReply> InvokeAsync(
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
MxCommandRequest request,
|
MxCommandRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -156,11 +140,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
return Task.FromResult(_invokeReplies.Dequeue());
|
return Task.FromResult(_invokeReplies.Dequeue());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Verifies that the StreamEventsAsync call is recorded and yields all enqueued events.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The StreamEventsRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
StreamEventsRequest request,
|
StreamEventsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -193,11 +173,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
_events.Add(gatewayEvent);
|
_events.Add(gatewayEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the acknowledge call and returns the next enqueued reply (or default).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The acknowledge alarm request.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
AcknowledgeAlarmRequest request,
|
AcknowledgeAlarmRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -218,11 +194,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the query call and yields each enqueued snapshot.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The query active alarms request.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||||
QueryActiveAlarmsRequest request,
|
QueryActiveAlarmsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -251,11 +223,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
_activeAlarmSnapshots.Add(snapshot);
|
_activeAlarmSnapshots.Add(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the stream-alarms call and yields each enqueued feed message.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The stream alarms request.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
StreamAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag.
|
/// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
|
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
|
||||||
{
|
{
|
||||||
@@ -27,6 +28,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync returns false when the server reports NotOk.
|
/// Verifies that TestConnectionAsync returns false when the server reports NotOk.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
|
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
|
||||||
{
|
{
|
||||||
@@ -42,6 +44,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that GetLastDeployTimeAsync returns null when the server reports not present.
|
/// Verifies that GetLastDeployTimeAsync returns null when the server reports not present.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
|
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
|
||||||
{
|
{
|
||||||
@@ -58,6 +61,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present.
|
/// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
|
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
|
||||||
{
|
{
|
||||||
@@ -79,6 +83,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that DiscoverHierarchyAsync returns the objects from the server reply.
|
/// Verifies that DiscoverHierarchyAsync returns the objects from the server reply.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
|
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
|
||||||
{
|
{
|
||||||
@@ -141,6 +146,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport.
|
/// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
|
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
|
||||||
{
|
{
|
||||||
@@ -161,6 +167,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
|
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
|
||||||
{
|
{
|
||||||
@@ -184,6 +191,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request.
|
/// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
|
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
|
||||||
{
|
{
|
||||||
@@ -218,6 +226,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
|
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
|
||||||
{
|
{
|
||||||
@@ -235,6 +244,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures.
|
/// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
|
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
|
||||||
{
|
{
|
||||||
@@ -251,6 +261,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that WatchDeployEventsAsync delivers the bootstrap event.
|
/// Verifies that WatchDeployEventsAsync delivers the bootstrap event.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
|
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
|
||||||
{
|
{
|
||||||
@@ -287,6 +298,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that WatchDeployEventsAsync delivers multiple events in order.
|
/// Verifies that WatchDeployEventsAsync delivers multiple events in order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
|
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
|
||||||
{
|
{
|
||||||
@@ -325,6 +337,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled.
|
/// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
|
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
|
||||||
{
|
{
|
||||||
@@ -369,6 +382,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed.
|
/// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
|
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
|
||||||
{
|
{
|
||||||
@@ -384,6 +398,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed.
|
/// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestConnectionAsync_ThrowsAfterDisposal()
|
public async Task TestConnectionAsync_ThrowsAfterDisposal()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// Verifies that calling BrowseAsync with no parent returns the root nodes
|
/// Verifies that calling BrowseAsync with no parent returns the root nodes
|
||||||
/// from the first BrowseChildren reply and surfaces the per-child has-children hint.
|
/// from the first BrowseChildren reply and surfaces the per-child has-children hint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Browse_NoParent_ReturnsRoots()
|
public async Task Browse_NoParent_ReturnsRoots()
|
||||||
{
|
{
|
||||||
@@ -36,6 +37,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
|
/// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Expand_PopulatesChildrenAndMarksExpanded()
|
public async Task Expand_PopulatesChildrenAndMarksExpanded()
|
||||||
{
|
{
|
||||||
@@ -62,6 +64,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
|
/// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Expand_CalledTwice_NoSecondRpc()
|
public async Task Expand_CalledTwice_NoSecondRpc()
|
||||||
{
|
{
|
||||||
@@ -86,6 +89,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
|
/// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Expand_UnknownParent_ThrowsMxGatewayException()
|
public async Task Expand_UnknownParent_ThrowsMxGatewayException()
|
||||||
{
|
{
|
||||||
@@ -113,6 +117,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
|
/// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Expand_MultiPageSiblings_GathersAllPages()
|
public async Task Expand_MultiPageSiblings_GathersAllPages()
|
||||||
{
|
{
|
||||||
@@ -147,6 +152,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
|
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
|
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
|
||||||
{
|
{
|
||||||
@@ -178,6 +184,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
|
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Browse_WithFilter_ForwardsToRequest()
|
public async Task Browse_WithFilter_ForwardsToRequest()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|||||||
public sealed class MxGatewayClientAlarmsTests
|
public sealed class MxGatewayClientAlarmsTests
|
||||||
{
|
{
|
||||||
/// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary>
|
/// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
|
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
|
||||||
{
|
{
|
||||||
@@ -48,6 +49,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
|
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
|
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
|
||||||
{
|
{
|
||||||
@@ -72,6 +74,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
|
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
|
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
|
||||||
{
|
{
|
||||||
@@ -97,6 +100,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary>
|
/// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
|
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
|
||||||
{
|
{
|
||||||
@@ -122,6 +126,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary>
|
/// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
|
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
|
||||||
{
|
{
|
||||||
@@ -142,6 +147,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary>
|
/// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
|
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
|
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
|
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
|
||||||
{
|
{
|
||||||
@@ -38,6 +39,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
|
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
|
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
|
||||||
{
|
{
|
||||||
@@ -83,6 +85,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
||||||
{
|
{
|
||||||
@@ -107,6 +110,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
|
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
||||||
{
|
{
|
||||||
@@ -149,6 +153,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
|
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
|
||||||
{
|
{
|
||||||
@@ -188,6 +193,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
||||||
{
|
{
|
||||||
@@ -230,6 +236,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
||||||
{
|
{
|
||||||
@@ -261,6 +268,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
|
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
|
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
|
||||||
{
|
{
|
||||||
@@ -291,6 +299,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
|
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
|
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
|
||||||
{
|
{
|
||||||
@@ -361,6 +370,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
|
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
|
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
|
||||||
{
|
{
|
||||||
@@ -415,6 +425,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
|
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
|
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
|
||||||
{
|
{
|
||||||
@@ -450,6 +461,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
|
/// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
|
public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
|
||||||
{
|
{
|
||||||
@@ -476,6 +488,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
|
/// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
|
public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
|
||||||
{
|
{
|
||||||
@@ -520,6 +533,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// against exit code 0.
|
/// against exit code 0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
|
/// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("stream-alarms")]
|
[InlineData("stream-alarms")]
|
||||||
[InlineData("acknowledge-alarm")]
|
[InlineData("acknowledge-alarm")]
|
||||||
@@ -574,6 +588,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// against a zero server handle. The fix must fail loudly with a
|
/// against a zero server handle. The fix must fail loudly with a
|
||||||
/// descriptive <see cref="MxGatewayException"/>.
|
/// descriptive <see cref="MxGatewayException"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
|
public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
|
||||||
{
|
{
|
||||||
@@ -624,6 +639,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
|
/// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
|
||||||
/// the bench must exit promptly when the supplied token cancels.
|
/// the bench must exit promptly when the supplied token cancels.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
|
public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
|
||||||
{
|
{
|
||||||
@@ -718,6 +734,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// to ~49.7 days. The fix must reject negatives with a clear error.
|
/// to ~49.7 days. The fix must reject negatives with a clear error.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
|
/// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("read-bulk")]
|
[InlineData("read-bulk")]
|
||||||
[InlineData("bench-read-bulk")]
|
[InlineData("bench-read-bulk")]
|
||||||
@@ -880,7 +897,8 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
|
/// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
|
||||||
public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; }
|
public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Releases resources held by the fake CLI client.</summary>
|
||||||
|
/// <returns>A completed value task.</returns>
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|||||||
public sealed class MxGatewayClientSessionTests
|
public sealed class MxGatewayClientSessionTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary>
|
/// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
|
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
|
||||||
{
|
{
|
||||||
@@ -22,6 +23,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that open session returns a session with the raw open reply.</summary>
|
/// <summary>Verifies that open session returns a session with the raw open reply.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
|
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
|
||||||
{
|
{
|
||||||
@@ -37,6 +39,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that register builds a register command and returns server handle.</summary>
|
/// <summary>Verifies that register builds a register command and returns server handle.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
|
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
|
||||||
{
|
{
|
||||||
@@ -62,6 +65,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that add item 2 builds a command with the specified context.</summary>
|
/// <summary>Verifies that add item 2 builds a command with the specified context.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
|
public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
|
||||||
{
|
{
|
||||||
@@ -87,6 +91,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that write raw builds a write command with the raw value.</summary>
|
/// <summary>Verifies that write raw builds a write command with the raw value.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
|
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
|
||||||
{
|
{
|
||||||
@@ -118,6 +123,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary>
|
/// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
|
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
|
||||||
{
|
{
|
||||||
@@ -146,6 +152,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary>
|
/// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults()
|
public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults()
|
||||||
{
|
{
|
||||||
@@ -185,6 +192,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
|
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
|
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
|
||||||
{
|
{
|
||||||
@@ -216,6 +224,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that close is explicit and idempotent.</summary>
|
/// <summary>Verifies that close is explicit and idempotent.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CloseAsync_IsExplicitAndIdempotent()
|
public async Task CloseAsync_IsExplicitAndIdempotent()
|
||||||
{
|
{
|
||||||
@@ -232,6 +241,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
|
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
|
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
|
||||||
{
|
{
|
||||||
@@ -256,6 +266,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
|
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
|
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
|
||||||
{
|
{
|
||||||
@@ -269,6 +280,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary>
|
/// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_DoesNotRetryWriteCommand()
|
public async Task InvokeAsync_DoesNotRetryWriteCommand()
|
||||||
{
|
{
|
||||||
@@ -284,6 +296,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary>
|
/// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeHelpers_PassCancellationTokenToTransport()
|
public async Task InvokeHelpers_PassCancellationTokenToTransport()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|||||||
public sealed class MxGatewayGeneratedContractTests
|
public sealed class MxGatewayGeneratedContractTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary>
|
/// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
|
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -337,6 +337,9 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds a <see cref="BrowseChildrenRequest"/> from the provided options.</summary>
|
||||||
|
/// <param name="options">Browse children options to convert.</param>
|
||||||
|
/// <returns>The constructed request message.</returns>
|
||||||
internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
|
internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
@@ -424,6 +427,7 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Closes the gRPC channel and releases resources.
|
/// Closes the gRPC channel and releases resources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous dispose operation.</returns>
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
@@ -493,6 +497,9 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
||||||
CreateHttpHandlerForTests(options);
|
CreateHttpHandlerForTests(options);
|
||||||
|
|
||||||
|
/// <summary>Creates an <see cref="HttpMessageHandler"/> configured from the provided options for test use.</summary>
|
||||||
|
/// <param name="options">Client options used to configure TLS and timeouts.</param>
|
||||||
|
/// <returns>The configured HTTP message handler.</returns>
|
||||||
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
||||||
{
|
{
|
||||||
SocketsHttpHandler handler = new()
|
SocketsHttpHandler handler = new()
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
MxGatewayClientOptions options,
|
MxGatewayClientOptions options,
|
||||||
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
|
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets the gateway client options.
|
|
||||||
/// </summary>
|
|
||||||
public MxGatewayClientOptions Options { get; } = options;
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -91,7 +89,11 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Streams deploy events from the Galaxy Repository, using an explicit cancellation token that overrides the call options token when provided.</summary>
|
||||||
|
/// <param name="request">The watch deploy events request.</param>
|
||||||
|
/// <param name="callOptions">Call options for the underlying gRPC call.</param>
|
||||||
|
/// <param name="cancellationToken">Optional cancellation token; takes precedence over the token in <paramref name="callOptions"/> when cancellable.</param>
|
||||||
|
/// <returns>An async enumerable of deploy events.</returns>
|
||||||
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
WatchDeployEventsRequest request,
|
WatchDeployEventsRequest request,
|
||||||
CallOptions callOptions,
|
CallOptions callOptions,
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
MxGatewayClientOptions options,
|
MxGatewayClientOptions options,
|
||||||
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
|
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets the gateway client options.
|
|
||||||
/// </summary>
|
|
||||||
public MxGatewayClientOptions Options { get; } = options;
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -74,7 +72,11 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Streams MXAccess events from the gateway, forwarding an explicit cancellation token to the stream reader.</summary>
|
||||||
|
/// <param name="request">The stream events request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
|
||||||
|
/// <returns>An async enumerable of MXAccess events.</returns>
|
||||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
StreamEventsRequest request,
|
StreamEventsRequest request,
|
||||||
CallOptions callOptions,
|
CallOptions callOptions,
|
||||||
@@ -133,7 +135,11 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Queries active alarms from the gateway, forwarding an explicit cancellation token to the stream reader.</summary>
|
||||||
|
/// <param name="request">The query active alarms request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
|
||||||
|
/// <returns>An async enumerable of active alarm snapshots.</returns>
|
||||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||||
QueryActiveAlarmsRequest request,
|
QueryActiveAlarmsRequest request,
|
||||||
CallOptions callOptions,
|
CallOptions callOptions,
|
||||||
@@ -175,7 +181,11 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
return QueryActiveAlarmsAsync(request, callOptions);
|
return QueryActiveAlarmsAsync(request, callOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Streams alarm feed messages from the gateway, forwarding an explicit cancellation token to the stream reader.</summary>
|
||||||
|
/// <param name="request">The stream alarms request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
|
||||||
|
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
StreamAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
CallOptions callOptions,
|
CallOptions callOptions,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
/// <summary>Tests the connection to the Galaxy Repository server.</summary>
|
/// <summary>Tests the connection to the Galaxy Repository server.</summary>
|
||||||
/// <param name="request">The test connection request.</param>
|
/// <param name="request">The test connection request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
/// <returns>A task that resolves to the test connection reply.</returns>
|
||||||
Task<TestConnectionReply> TestConnectionAsync(
|
Task<TestConnectionReply> TestConnectionAsync(
|
||||||
TestConnectionRequest request,
|
TestConnectionRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
@@ -22,6 +23,7 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
/// <summary>Gets the last deploy time from the Galaxy Repository server.</summary>
|
/// <summary>Gets the last deploy time from the Galaxy Repository server.</summary>
|
||||||
/// <param name="request">The get last deploy time request.</param>
|
/// <param name="request">The get last deploy time request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
/// <returns>A task that resolves to the last deploy time reply.</returns>
|
||||||
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
||||||
GetLastDeployTimeRequest request,
|
GetLastDeployTimeRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
@@ -29,6 +31,7 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
/// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary>
|
/// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary>
|
||||||
/// <param name="request">The discover hierarchy request.</param>
|
/// <param name="request">The discover hierarchy request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
/// <returns>A task that resolves to the hierarchy discovery reply.</returns>
|
||||||
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
@@ -36,6 +39,7 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
|
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
|
||||||
/// <param name="request">The browse children request.</param>
|
/// <param name="request">The browse children request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
/// <returns>A task that resolves to the browse children reply.</returns>
|
||||||
Task<BrowseChildrenReply> BrowseChildrenAsync(
|
Task<BrowseChildrenReply> BrowseChildrenAsync(
|
||||||
BrowseChildrenRequest request,
|
BrowseChildrenRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
@@ -43,6 +47,7 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
|
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
|
||||||
/// <param name="request">The watch deploy events request.</param>
|
/// <param name="request">The watch deploy events request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
/// <returns>An async enumerable of deploy events.</returns>
|
||||||
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
WatchDeployEventsRequest request,
|
WatchDeployEventsRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ public sealed class LazyBrowseNode
|
|||||||
private readonly SemaphoreSlim _expandLock = new(1, 1);
|
private readonly SemaphoreSlim _expandLock = new(1, 1);
|
||||||
private bool _isExpanded;
|
private bool _isExpanded;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of <see cref="LazyBrowseNode"/>.</summary>
|
||||||
|
/// <param name="client">The repository client used to fetch children.</param>
|
||||||
|
/// <param name="object">The underlying Galaxy object for this node.</param>
|
||||||
|
/// <param name="hasChildrenHint">True when the server reports the node has at least one matching descendant.</param>
|
||||||
|
/// <param name="options">Options controlling child browse behavior.</param>
|
||||||
internal LazyBrowseNode(
|
internal LazyBrowseNode(
|
||||||
GalaxyRepositoryClient client,
|
GalaxyRepositoryClient client,
|
||||||
GalaxyObject @object,
|
GalaxyObject @object,
|
||||||
@@ -49,6 +54,7 @@ public sealed class LazyBrowseNode
|
|||||||
/// (after the first completes) return immediately.
|
/// (after the first completes) return immediately.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task ExpandAsync(CancellationToken cancellationToken = default)
|
public async Task ExpandAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (_isExpanded)
|
if (_isExpanded)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public static class MxCommandReplyExtensions
|
|||||||
{
|
{
|
||||||
/// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary>
|
/// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary>
|
||||||
/// <param name="reply">The command reply to check.</param>
|
/// <param name="reply">The command reply to check.</param>
|
||||||
|
/// <returns>The same <paramref name="reply"/> for fluent chaining when validation passes.</returns>
|
||||||
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
|
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(reply);
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
@@ -24,6 +25,7 @@ public static class MxCommandReplyExtensions
|
|||||||
|
|
||||||
/// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary>
|
/// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary>
|
||||||
/// <param name="reply">The command reply to check.</param>
|
/// <param name="reply">The command reply to check.</param>
|
||||||
|
/// <returns>The same <paramref name="reply"/> for fluent chaining when validation passes.</returns>
|
||||||
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
|
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(reply);
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Disposes the client and releases all resources.
|
/// Disposes the client and releases all resources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous dispose operation.</returns>
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
@@ -318,6 +319,9 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
||||||
CreateHttpHandlerForTests(options);
|
CreateHttpHandlerForTests(options);
|
||||||
|
|
||||||
|
/// <summary>Creates an <see cref="HttpMessageHandler"/> configured from the provided options for test use.</summary>
|
||||||
|
/// <param name="options">Client options used to configure TLS and timeouts.</param>
|
||||||
|
/// <returns>The configured HTTP message handler.</returns>
|
||||||
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
||||||
{
|
{
|
||||||
SocketsHttpHandler handler = new()
|
SocketsHttpHandler handler = new()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ internal static class MxGatewayClientRetryPolicy
|
|||||||
/// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary>
|
/// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary>
|
||||||
/// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param>
|
/// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param>
|
||||||
/// <param name="logger">Optional logger for retry diagnostics.</param>
|
/// <param name="logger">Optional logger for retry diagnostics.</param>
|
||||||
|
/// <returns>A configured <see cref="ResiliencePipeline"/> with exponential-backoff retry.</returns>
|
||||||
public static ResiliencePipeline Create(
|
public static ResiliencePipeline Create(
|
||||||
MxGatewayClientRetryOptions options,
|
MxGatewayClientRetryOptions options,
|
||||||
ILogger? logger)
|
ILogger? logger)
|
||||||
@@ -42,6 +43,7 @@ internal static class MxGatewayClientRetryPolicy
|
|||||||
|
|
||||||
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
|
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
|
||||||
/// <param name="kind">The command kind to check.</param>
|
/// <param name="kind">The command kind to check.</param>
|
||||||
|
/// <returns><see langword="true"/> if the command kind is safe to retry; otherwise <see langword="false"/>.</returns>
|
||||||
public static bool IsRetryableCommand(MxCommandKind kind)
|
public static bool IsRetryableCommand(MxCommandKind kind)
|
||||||
{
|
{
|
||||||
return kind is MxCommandKind.Ping
|
return kind is MxCommandKind.Ping
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task AdviseAsync(
|
public async Task AdviseAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -252,6 +253,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task UnAdviseAsync(
|
public async Task UnAdviseAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -293,6 +295,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task RemoveItemAsync(
|
public async Task RemoveItemAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -675,6 +678,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="value">The value to write.</param>
|
/// <param name="value">The value to write.</param>
|
||||||
/// <param name="userId">User ID context for the write.</param>
|
/// <param name="userId">User ID context for the write.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task WriteAsync(
|
public async Task WriteAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -729,6 +733,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="timestampValue">The timestamp to write with the value.</param>
|
/// <param name="timestampValue">The timestamp to write with the value.</param>
|
||||||
/// <param name="userId">User ID context for the write.</param>
|
/// <param name="userId">User ID context for the write.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task Write2Async(
|
public async Task Write2Async(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -821,6 +826,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Closes the session and releases resources.
|
/// Closes the session and releases resources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await CloseAsync().ConfigureAwait(false);
|
await CloseAsync().ConfigureAwait(false);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public static class MxStatusProxyExtensions
|
|||||||
{
|
{
|
||||||
/// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary>
|
/// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary>
|
||||||
/// <param name="status">The status to check.</param>
|
/// <param name="status">The status to check.</param>
|
||||||
|
/// <returns><c>true</c> if the status is successful; <c>false</c> otherwise.</returns>
|
||||||
public static bool IsSuccess(this MxStatusProxy status)
|
public static bool IsSuccess(this MxStatusProxy status)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(status);
|
ArgumentNullException.ThrowIfNull(status);
|
||||||
@@ -17,6 +18,7 @@ public static class MxStatusProxyExtensions
|
|||||||
|
|
||||||
/// <summary>Returns a formatted summary of the status for diagnostic output.</summary>
|
/// <summary>Returns a formatted summary of the status for diagnostic output.</summary>
|
||||||
/// <param name="status">The status to summarize.</param>
|
/// <param name="status">The status to summarize.</param>
|
||||||
|
/// <returns>A human-readable string combining category, source, detail, and diagnostic text.</returns>
|
||||||
public static string ToDiagnosticSummary(this MxStatusProxy status)
|
public static string ToDiagnosticSummary(this MxStatusProxy status)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(status);
|
ArgumentNullException.ThrowIfNull(status);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a boolean value to an MxValue with MxDataType.Boolean.
|
/// Converts a boolean value to an MxValue with MxDataType.Boolean.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">Scalar boolean value to wrap.</param>
|
/// <param name="value">Scalar boolean value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Boolean</c>.</returns>
|
||||||
public static MxValue ToMxValue(this bool value)
|
public static MxValue ToMxValue(this bool value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -28,6 +29,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a 32-bit integer value to an MxValue with MxDataType.Integer.
|
/// Converts a 32-bit integer value to an MxValue with MxDataType.Integer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">32-bit integer value to wrap.</param>
|
/// <param name="value">32-bit integer value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c>.</returns>
|
||||||
public static MxValue ToMxValue(this int value)
|
public static MxValue ToMxValue(this int value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -42,6 +44,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a 64-bit integer value to an MxValue with MxDataType.Integer.
|
/// Converts a 64-bit integer value to an MxValue with MxDataType.Integer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">64-bit integer value to wrap.</param>
|
/// <param name="value">64-bit integer value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c>.</returns>
|
||||||
public static MxValue ToMxValue(this long value)
|
public static MxValue ToMxValue(this long value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -56,6 +59,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a single-precision floating-point value to an MxValue with MxDataType.Float.
|
/// Converts a single-precision floating-point value to an MxValue with MxDataType.Float.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">Single-precision floating-point value to wrap.</param>
|
/// <param name="value">Single-precision floating-point value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Float</c>.</returns>
|
||||||
public static MxValue ToMxValue(this float value)
|
public static MxValue ToMxValue(this float value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -70,6 +74,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a double-precision floating-point value to an MxValue with MxDataType.Double.
|
/// Converts a double-precision floating-point value to an MxValue with MxDataType.Double.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">Double-precision floating-point value to wrap.</param>
|
/// <param name="value">Double-precision floating-point value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Double</c>.</returns>
|
||||||
public static MxValue ToMxValue(this double value)
|
public static MxValue ToMxValue(this double value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -84,6 +89,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a string value to an MxValue with MxDataType.String.
|
/// Converts a string value to an MxValue with MxDataType.String.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">String value to wrap.</param>
|
/// <param name="value">String value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.String</c>.</returns>
|
||||||
public static MxValue ToMxValue(this string value)
|
public static MxValue ToMxValue(this string value)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(value);
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
@@ -100,6 +106,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a DateTimeOffset value to an MxValue with MxDataType.Time.
|
/// Converts a DateTimeOffset value to an MxValue with MxDataType.Time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">DateTimeOffset value to wrap.</param>
|
/// <param name="value">DateTimeOffset value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c>.</returns>
|
||||||
public static MxValue ToMxValue(this DateTimeOffset value)
|
public static MxValue ToMxValue(this DateTimeOffset value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -114,6 +121,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a DateTime value to an MxValue with MxDataType.Time.
|
/// Converts a DateTime value to an MxValue with MxDataType.Time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">DateTime value to wrap.</param>
|
/// <param name="value">DateTime value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c>.</returns>
|
||||||
public static MxValue ToMxValue(this DateTime value)
|
public static MxValue ToMxValue(this DateTime value)
|
||||||
{
|
{
|
||||||
return new DateTimeOffset(
|
return new DateTimeOffset(
|
||||||
@@ -127,6 +135,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a boolean array to an MxValue with MxDataType.Boolean.
|
/// Converts a boolean array to an MxValue with MxDataType.Boolean.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of boolean values to wrap.</param>
|
/// <param name="values">Array of boolean values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Boolean</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<bool> values)
|
public static MxValue ToMxValue(this IReadOnlyList<bool> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -145,6 +154,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a 32-bit integer array to an MxValue with MxDataType.Integer.
|
/// Converts a 32-bit integer array to an MxValue with MxDataType.Integer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of 32-bit integer values to wrap.</param>
|
/// <param name="values">Array of 32-bit integer values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<int> values)
|
public static MxValue ToMxValue(this IReadOnlyList<int> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -163,6 +173,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a 64-bit integer array to an MxValue with MxDataType.Integer.
|
/// Converts a 64-bit integer array to an MxValue with MxDataType.Integer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of 64-bit integer values to wrap.</param>
|
/// <param name="values">Array of 64-bit integer values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<long> values)
|
public static MxValue ToMxValue(this IReadOnlyList<long> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -181,6 +192,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a single-precision floating-point array to an MxValue with MxDataType.Float.
|
/// Converts a single-precision floating-point array to an MxValue with MxDataType.Float.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of single-precision floating-point values to wrap.</param>
|
/// <param name="values">Array of single-precision floating-point values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Float</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<float> values)
|
public static MxValue ToMxValue(this IReadOnlyList<float> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -199,6 +211,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a double-precision floating-point array to an MxValue with MxDataType.Double.
|
/// Converts a double-precision floating-point array to an MxValue with MxDataType.Double.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of double-precision floating-point values to wrap.</param>
|
/// <param name="values">Array of double-precision floating-point values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Double</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<double> values)
|
public static MxValue ToMxValue(this IReadOnlyList<double> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -217,6 +230,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a string array to an MxValue with MxDataType.String.
|
/// Converts a string array to an MxValue with MxDataType.String.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of string values to wrap.</param>
|
/// <param name="values">Array of string values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.String</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<string> values)
|
public static MxValue ToMxValue(this IReadOnlyList<string> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -235,6 +249,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a DateTimeOffset array to an MxValue with MxDataType.Time.
|
/// Converts a DateTimeOffset array to an MxValue with MxDataType.Time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of DateTimeOffset values to wrap.</param>
|
/// <param name="values">Array of DateTimeOffset values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
|
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -253,6 +268,7 @@ public static class MxValueExtensions
|
|||||||
/// Gets the projection kind (field name) of the given MxValue's current oneof value.
|
/// Gets the projection kind (field name) of the given MxValue's current oneof value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">The MxValue whose oneof projection kind is returned.</param>
|
/// <param name="value">The MxValue whose oneof projection kind is returned.</param>
|
||||||
|
/// <returns>The JSON field name of the active oneof case, or <c>"nullValue"</c>/<c>"unspecified"</c> for null/unset values.</returns>
|
||||||
public static string GetProjectionKind(this MxValue value)
|
public static string GetProjectionKind(this MxValue value)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(value);
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
@@ -276,6 +292,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues.
|
/// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">The MxValue to convert.</param>
|
/// <param name="value">The MxValue to convert.</param>
|
||||||
|
/// <returns>The boxed CLR value, or null if the MxValue represents a null.</returns>
|
||||||
public static object? ToClrValue(this MxValue value)
|
public static object? ToClrValue(this MxValue value)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(value);
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
@@ -299,6 +316,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts an MxArray to a CLR array; returns null if the array does not have a known element type.
|
/// Converts an MxArray to a CLR array; returns null if the array does not have a known element type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="array">The MxArray to convert.</param>
|
/// <param name="array">The MxArray to convert.</param>
|
||||||
|
/// <returns>A CLR array of the appropriate element type, or null for unknown element types.</returns>
|
||||||
public static object? ToClrArrayValue(this MxArray array)
|
public static object? ToClrArrayValue(this MxArray array)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(array);
|
ArgumentNullException.ThrowIfNull(array);
|
||||||
@@ -328,6 +346,7 @@ public static class MxValueExtensions
|
|||||||
/// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param>
|
/// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param>
|
||||||
/// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param>
|
/// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param>
|
||||||
/// <param name="rawDataType">Optional MXAccess data type override.</param>
|
/// <param name="rawDataType">Optional MXAccess data type override.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Unknown</c> and the raw byte payload.</returns>
|
||||||
public static MxValue ToRawMxValue(
|
public static MxValue ToRawMxValue(
|
||||||
byte[] value,
|
byte[] value,
|
||||||
string variantType,
|
string variantType,
|
||||||
|
|||||||
+6
-6
@@ -4,7 +4,7 @@ The metrics subsystem exposes counters, histograms, and observable gauges that d
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway.Server` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock.
|
`GatewayMetrics` is a singleton (registered in `GatewayApplication.cs`) that owns a single `Meter` named `ZB.MOM.WW.MxGateway` and a set of synchronised counters, histograms, and observable gauges. Subsystems call typed mutator methods (`SessionOpened`, `CommandFailed`, `EventReceived`, etc.) rather than touching the `Meter` directly, which keeps the OpenTelemetry instrument names and tag conventions in one place. A `lock (_syncRoot)` block guards the scalar fields used by `GetSnapshot`, while per-event maps use `ConcurrentDictionary<string, long>` so the hot event path avoids the lock.
|
||||||
|
|
||||||
## Meter and OpenTelemetry Compatibility
|
## Meter and OpenTelemetry Compatibility
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ The meter name is exposed as a constant so that hosting code can register it wit
|
|||||||
```csharp
|
```csharp
|
||||||
public sealed class GatewayMetrics : IDisposable
|
public sealed class GatewayMetrics : IDisposable
|
||||||
{
|
{
|
||||||
public const string MeterName = "ZB.MOM.WW.MxGateway.Server";
|
public const string MeterName = "ZB.MOM.WW.MxGateway";
|
||||||
|
|
||||||
public GatewayMetrics()
|
public GatewayMetrics()
|
||||||
{
|
{
|
||||||
@@ -50,12 +50,12 @@ All counters are `Counter<long>`. Tag values come from the call sites listed und
|
|||||||
|
|
||||||
### Histograms
|
### Histograms
|
||||||
|
|
||||||
Histograms record durations in milliseconds (the `unit` argument on `CreateHistogram`):
|
Histograms record durations in seconds (the `unit` argument on `CreateHistogram`):
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "ms");
|
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
|
||||||
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "ms");
|
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
|
||||||
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "ms");
|
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
|
||||||
```
|
```
|
||||||
|
|
||||||
| Instrument | Tags | What it measures |
|
| Instrument | Tags | What it measures |
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ against them, and what's needed to add a gw-specific role.
|
|||||||
| Host | `localhost` |
|
| Host | `localhost` |
|
||||||
| Port | `3893` |
|
| Port | `3893` |
|
||||||
| LDAPS | disabled in dev (set `[ldaps]` block to enable) |
|
| LDAPS | disabled in dev (set `[ldaps]` block to enable) |
|
||||||
| Base DN | `dc=lmxopcua,dc=local` |
|
| Base DN | `dc=zb,dc=local` |
|
||||||
| Bind DN format | `cn={username},dc=lmxopcua,dc=local` |
|
| Bind DN format | `cn={username},dc=zb,dc=local` |
|
||||||
| Group OU | `ou=<groupname>,ou=groups,dc=lmxopcua,dc=local` |
|
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
|
||||||
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
|
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
|
||||||
|
|
||||||
## Pre-existing groups (LmxOpcUa role taxonomy)
|
## Pre-existing groups (LmxOpcUa role taxonomy)
|
||||||
@@ -33,11 +33,11 @@ LmxOpcUa write rights doesn't need a second account for the gw.
|
|||||||
|
|
||||||
| Group | GID | DN | LmxOpcUa meaning | Suggested mxgw mapping |
|
| Group | GID | DN | LmxOpcUa meaning | Suggested mxgw mapping |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| ReadOnly | 5501 | `ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local` | Browse + read OPC UA nodes | `Browse` + `Subscribe` (read paths only) |
|
| ReadOnly | 5501 | `ou=ReadOnly,ou=groups,dc=zb,dc=local` | Browse + read OPC UA nodes | `Browse` + `Subscribe` (read paths only) |
|
||||||
| WriteOperate | 5502 | `ou=WriteOperate,ou=groups,dc=lmxopcua,dc=local` | Write FreeAccess / Operate attrs | `Write` (plain) |
|
| WriteOperate | 5502 | `ou=WriteOperate,ou=groups,dc=zb,dc=local` | Write FreeAccess / Operate attrs | `Write` (plain) |
|
||||||
| WriteTune | 5504 | `ou=WriteTune,ou=groups,dc=lmxopcua,dc=local` | Write Tune attrs | `WriteSecured` (Tune only) |
|
| WriteTune | 5504 | `ou=WriteTune,ou=groups,dc=zb,dc=local` | Write Tune attrs | `WriteSecured` (Tune only) |
|
||||||
| WriteConfigure | 5505 | `ou=WriteConfigure,ou=groups,dc=lmxopcua,dc=local` | Write Configure attrs | `WriteSecured` (Configure) |
|
| WriteConfigure | 5505 | `ou=WriteConfigure,ou=groups,dc=zb,dc=local` | Write Configure attrs | `WriteSecured` (Configure) |
|
||||||
| AlarmAck | 5503 | `ou=AlarmAck,ou=groups,dc=lmxopcua,dc=local` | Acknowledge alarms | gw alarm-ack RPC, when added |
|
| AlarmAck | 5503 | `ou=AlarmAck,ou=groups,dc=zb,dc=local` | Acknowledge alarms | gw alarm-ack RPC, when added |
|
||||||
|
|
||||||
**A user can be in multiple groups** — `othergroups = [...]` in the
|
**A user can be in multiple groups** — `othergroups = [...]` in the
|
||||||
config is a list. `admin` is the canonical example (in every role
|
config is a list. `admin` is the canonical example (in every role
|
||||||
@@ -67,12 +67,18 @@ GLAuth config — it must be provisioned before dashboard authn or the
|
|||||||
LDAP live tests work. See [Provisioning the GwAdmin
|
LDAP live tests work. See [Provisioning the GwAdmin
|
||||||
group](#provisioning-the-gwadmin-group) below.
|
group](#provisioning-the-gwadmin-group) below.
|
||||||
|
|
||||||
|
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
|
||||||
|
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
|
||||||
|
> maps to `Viewer`. This is a pure value rename via
|
||||||
|
> `MxGateway:Dashboard:GroupToRole` — same operations are authorized. (This
|
||||||
|
> dashboard role is distinct from the lowercase gRPC `admin` *API-key scope*.)
|
||||||
|
|
||||||
## Two bind patterns
|
## Two bind patterns
|
||||||
|
|
||||||
### 1. Direct bind (simplest)
|
### 1. Direct bind (simplest)
|
||||||
|
|
||||||
```
|
```
|
||||||
DN: cn=admin,dc=lmxopcua,dc=local
|
DN: cn=admin,dc=zb,dc=local
|
||||||
Password: admin123
|
Password: admin123
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -84,9 +90,9 @@ by `sAMAccountName`, not `cn`. Use this only for dev convenience.
|
|||||||
### 2. Bind-then-search (production-grade)
|
### 2. Bind-then-search (production-grade)
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Bind as the service account (cn=serviceaccount,dc=lmxopcua,dc=local
|
1. Bind as the service account (cn=serviceaccount,dc=zb,dc=local
|
||||||
/ serviceaccount123).
|
/ serviceaccount123).
|
||||||
2. Search under dc=lmxopcua,dc=local with filter
|
2. Search under dc=zb,dc=local with filter
|
||||||
(uid=<entered-username>) — or any attribute the deployment
|
(uid=<entered-username>) — or any attribute the deployment
|
||||||
identifies users by. GLAuth populates uid + cn.
|
identifies users by. GLAuth populates uid + cn.
|
||||||
3. Read the returned entry's DN + memberOf list (groups).
|
3. Read the returned entry's DN + memberOf list (groups).
|
||||||
@@ -116,8 +122,8 @@ ldap:
|
|||||||
port: 3893
|
port: 3893
|
||||||
useTls: false
|
useTls: false
|
||||||
allowInsecureLdap: true # dev only
|
allowInsecureLdap: true # dev only
|
||||||
searchBase: "dc=lmxopcua,dc=local"
|
searchBase: "dc=zb,dc=local"
|
||||||
serviceAccountDn: "cn=serviceaccount,dc=lmxopcua,dc=local"
|
serviceAccountDn: "cn=serviceaccount,dc=zb,dc=local"
|
||||||
serviceAccountPassword: "serviceaccount123"
|
serviceAccountPassword: "serviceaccount123"
|
||||||
userNameAttribute: "uid" # GLAuth populates this; AD uses sAMAccountName
|
userNameAttribute: "uid" # GLAuth populates this; AD uses sAMAccountName
|
||||||
displayNameAttribute: "cn"
|
displayNameAttribute: "cn"
|
||||||
@@ -131,7 +137,7 @@ ldap:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`groupAttribute` returns full DNs like
|
`groupAttribute` returns full DNs like
|
||||||
`ou=ReadOnly,ou=groups,dc=lmxopcua,dc=local` — the authenticator
|
`ou=ReadOnly,ou=groups,dc=zb,dc=local` — the authenticator
|
||||||
should strip the leading `ou=` (or `cn=` against AD) RDN value and
|
should strip the leading `ou=` (or `cn=` against AD) RDN value and
|
||||||
look that up in `groupToRole`.
|
look that up in `groupToRole`.
|
||||||
|
|
||||||
@@ -172,7 +178,7 @@ server:
|
|||||||
4. `nssm restart GLAuth`
|
4. `nssm restart GLAuth`
|
||||||
|
|
||||||
After the restart, `admin`'s `memberOf` includes
|
After the restart, `admin`'s `memberOf` includes
|
||||||
`ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local`, which the authenticator
|
`ou=GwAdmin,ou=groups,dc=zb,dc=local`, which the authenticator
|
||||||
strips to `GwAdmin` and matches against `RequiredGroup`. The same
|
strips to `GwAdmin` and matches against `RequiredGroup`. The same
|
||||||
pattern applies to any future permission that doesn't fit the existing
|
pattern applies to any future permission that doesn't fit the existing
|
||||||
five roles.
|
five roles.
|
||||||
@@ -201,7 +207,7 @@ $ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("localhost:
|
|||||||
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
|
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
|
||||||
$ldap.SessionOptions.ProtocolVersion = 3
|
$ldap.SessionOptions.ProtocolVersion = 3
|
||||||
$ldap.SessionOptions.SecureSocketLayer = $false
|
$ldap.SessionOptions.SecureSocketLayer = $false
|
||||||
$cred = New-Object System.Net.NetworkCredential("cn=admin,dc=lmxopcua,dc=local","admin123")
|
$cred = New-Object System.Net.NetworkCredential("cn=admin,dc=zb,dc=local","admin123")
|
||||||
$ldap.Bind($cred)
|
$ldap.Bind($cred)
|
||||||
"Bind OK"
|
"Bind OK"
|
||||||
```
|
```
|
||||||
@@ -210,8 +216,8 @@ Or via `ldapsearch` if you have OpenLDAP CLI tools:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
ldapsearch -x -H ldap://localhost:3893 \
|
ldapsearch -x -H ldap://localhost:3893 \
|
||||||
-D "cn=admin,dc=lmxopcua,dc=local" -w admin123 \
|
-D "cn=admin,dc=zb,dc=local" -w admin123 \
|
||||||
-b "dc=lmxopcua,dc=local" "(uid=admin)"
|
-b "dc=zb,dc=local" "(uid=admin)"
|
||||||
```
|
```
|
||||||
|
|
||||||
The response should list `admin`'s entry with `memberOf` populated for
|
The response should list `admin`'s entry with `memberOf` populated for
|
||||||
@@ -257,8 +263,8 @@ applies to mxaccessgw verbatim. Keys that change:
|
|||||||
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
|
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
|
||||||
| `UseTls` | `false` | `true` |
|
| `UseTls` | `false` | `true` |
|
||||||
| `AllowInsecureLdap` | `true` | `false` |
|
| `AllowInsecureLdap` | `true` | `false` |
|
||||||
| `SearchBase` | `dc=lmxopcua,dc=local` | `DC=corp,DC=example,DC=com` |
|
| `SearchBase` | `dc=zb,dc=local` | `DC=corp,DC=example,DC=com` |
|
||||||
| `ServiceAccountDn` | `cn=serviceaccount,dc=lmxopcua,dc=local` | `CN=MxGwSvc,OU=Service Accounts,DC=corp,...` |
|
| `ServiceAccountDn` | `cn=serviceaccount,dc=zb,dc=local` | `CN=MxGwSvc,OU=Service Accounts,DC=corp,...` |
|
||||||
| `UserNameAttribute` | `uid` | `sAMAccountName` (or `userPrincipalName`) |
|
| `UserNameAttribute` | `uid` | `sAMAccountName` (or `userPrincipalName`) |
|
||||||
| `GroupAttribute` | `memberOf` (unchanged) | `memberOf` (unchanged) |
|
| `GroupAttribute` | `memberOf` (unchanged) | `memberOf` (unchanged) |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<packageSources>
|
||||||
|
<clear />
|
||||||
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||||
|
<add key="dohertj2-gitea" value="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json" />
|
||||||
|
</packageSources>
|
||||||
|
<!-- nuget.org serves everything; the Gitea feed serves only the ZB.MOM.WW.* shared libs.
|
||||||
|
Credentials are NOT committed: they are provided per-developer at the user level. -->
|
||||||
|
<packageSourceMapping>
|
||||||
|
<packageSource key="nuget.org">
|
||||||
|
<package pattern="*" />
|
||||||
|
</packageSource>
|
||||||
|
<packageSource key="dohertj2-gitea">
|
||||||
|
<package pattern="ZB.MOM.WW.Health" />
|
||||||
|
<package pattern="ZB.MOM.WW.Health.*" />
|
||||||
|
<package pattern="ZB.MOM.WW.Telemetry" />
|
||||||
|
<package pattern="ZB.MOM.WW.Telemetry.*" />
|
||||||
|
<package pattern="ZB.MOM.WW.Configuration" />
|
||||||
|
<package pattern="ZB.MOM.WW.Auth" />
|
||||||
|
<package pattern="ZB.MOM.WW.Auth.*" />
|
||||||
|
<package pattern="ZB.MOM.WW.Audit" />
|
||||||
|
<package pattern="ZB.MOM.WW.Theme" />
|
||||||
|
</packageSource>
|
||||||
|
</packageSourceMapping>
|
||||||
|
</configuration>
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
using ZB.MOM.WW.Auth.Ldap;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
||||||
|
|
||||||
@@ -11,6 +14,7 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
|||||||
public sealed class DashboardLdapLiveTests
|
public sealed class DashboardLdapLiveTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
|
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
|
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
|
||||||
{
|
{
|
||||||
@@ -28,18 +32,18 @@ public sealed class DashboardLdapLiveTests
|
|||||||
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
|
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
|
||||||
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));
|
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
// IntegrationTests-023: DashboardAuthenticator.CreatePrincipal emits a
|
// IntegrationTests-023: DashboardAuthenticator builds the principal with a
|
||||||
// ClaimTypes.Role claim derived from MapGroupsToRoles. The seeded
|
// ClaimTypes.Role claim resolved from the LDAP groups via the
|
||||||
// GroupToRole map (GwAdmin -> Admin) means the admin principal must
|
// DashboardGroupRoleMapper. The seeded GroupToRole map (GwAdmin -> Admin)
|
||||||
// carry Role=Admin alongside the raw LDAP-group claim. A regression in
|
// means the admin principal must carry Role=Admin alongside the raw LDAP-group
|
||||||
// MapGroupsToRoles (returning an empty list, missing the RDN fallback)
|
// claim. A regression in the group→role mapping would fail this assertion.
|
||||||
// would silently pass without this assertion.
|
|
||||||
Assert.Contains(result.Principal.Claims, claim =>
|
Assert.Contains(result.Principal.Claims, claim =>
|
||||||
claim.Type == ClaimTypes.Role
|
claim.Type == ClaimTypes.Role
|
||||||
&& claim.Value == DashboardRoles.Admin);
|
&& claim.Value == DashboardRoles.Admin);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
|
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
|
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
|
||||||
{
|
{
|
||||||
@@ -56,10 +60,11 @@ public sealed class DashboardLdapLiveTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
|
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
|
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
|
||||||
{
|
{
|
||||||
// Exercises the LdapException branch: the user exists and the service
|
// Exercises the user-bind-failure branch: the user exists and the service
|
||||||
// account search succeeds, but the candidate bind is rejected.
|
// account search succeeds, but the candidate bind is rejected.
|
||||||
const string wrongPassword = "definitely-not-the-admin-password";
|
const string wrongPassword = "definitely-not-the-admin-password";
|
||||||
DashboardAuthenticator authenticator = CreateAuthenticator();
|
DashboardAuthenticator authenticator = CreateAuthenticator();
|
||||||
@@ -75,11 +80,12 @@ public sealed class DashboardLdapLiveTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that authentication with unknown username fails.</summary>
|
/// <summary>Verifies that authentication with unknown username fails.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_UnknownUsername_Fails()
|
public async Task AuthenticateAsync_UnknownUsername_Fails()
|
||||||
{
|
{
|
||||||
// Exercises the `candidate is null` branch: the service-account search
|
// Exercises the user-not-found branch: the service-account search returns no
|
||||||
// returns no entry, so no candidate bind is attempted.
|
// entry, so no candidate bind is attempted.
|
||||||
DashboardAuthenticator authenticator = CreateAuthenticator();
|
DashboardAuthenticator authenticator = CreateAuthenticator();
|
||||||
|
|
||||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
@@ -92,22 +98,18 @@ public sealed class DashboardLdapLiveTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that authentication fails gracefully when the server is unreachable.</summary>
|
/// <summary>Verifies that authentication fails gracefully when the server is unreachable.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
||||||
{
|
{
|
||||||
// Exercises the connect-failure path: a closed loopback port produces a
|
// Exercises the connect-failure path: a closed loopback port produces a
|
||||||
// connection error that DashboardAuthenticator must absorb into a Fail
|
// connection error that the shared LdapAuthService must absorb into a Fail
|
||||||
// result rather than propagating an exception to the dashboard.
|
// result rather than propagating an exception to the dashboard.
|
||||||
DashboardAuthenticator authenticator = new(
|
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
|
||||||
Options.Create(new GatewayOptions
|
{
|
||||||
{
|
// 1 is a reserved port number that no LDAP server listens on.
|
||||||
Ldap = new LdapOptions
|
Port = 1,
|
||||||
{
|
});
|
||||||
// 1 is a reserved port number that no LDAP server listens on.
|
|
||||||
Port = 1,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
NullLogger<DashboardAuthenticator>.Instance);
|
|
||||||
|
|
||||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||||
"admin",
|
"admin",
|
||||||
@@ -118,19 +120,48 @@ public sealed class DashboardLdapLiveTests
|
|||||||
Assert.Null(result.Principal);
|
Assert.Null(result.Principal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DashboardAuthenticator CreateAuthenticator()
|
private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions());
|
||||||
|
|
||||||
|
private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions)
|
||||||
{
|
{
|
||||||
return new DashboardAuthenticator(
|
GatewayOptions gatewayOptions = new()
|
||||||
Options.Create(new GatewayOptions
|
{
|
||||||
|
Dashboard = new DashboardOptions
|
||||||
{
|
{
|
||||||
Dashboard = new DashboardOptions
|
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
["GwAdmin"] = DashboardRoles.Admin,
|
||||||
{
|
|
||||||
["GwAdmin"] = DashboardRoles.Admin,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new DashboardAuthenticator(
|
||||||
|
new LdapAuthService(ldapOptions),
|
||||||
|
new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
|
||||||
NullLogger<DashboardAuthenticator>.Instance);
|
NullLogger<DashboardAuthenticator>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the shared library <see cref="LibraryLdapOptions"/> from the gateway's
|
||||||
|
/// default LDAP settings so the live tests exercise the same seeded directory the
|
||||||
|
/// gateway connects to (localhost:3893, plaintext, with AllowInsecure for dev).
|
||||||
|
/// </summary>
|
||||||
|
private static LibraryLdapOptions LibraryOptions()
|
||||||
|
{
|
||||||
|
ZB.MOM.WW.MxGateway.Server.Configuration.LdapOptions gateway = new();
|
||||||
|
return new LibraryLdapOptions
|
||||||
|
{
|
||||||
|
Enabled = gateway.Enabled,
|
||||||
|
Server = gateway.Server,
|
||||||
|
Port = gateway.Port,
|
||||||
|
Transport = gateway.Transport,
|
||||||
|
AllowInsecure = gateway.AllowInsecure,
|
||||||
|
SearchBase = gateway.SearchBase,
|
||||||
|
ServiceAccountDn = gateway.ServiceAccountDn,
|
||||||
|
ServiceAccountPassword = gateway.ServiceAccountPassword,
|
||||||
|
UserNameAttribute = gateway.UserNameAttribute,
|
||||||
|
DisplayNameAttribute = gateway.DisplayNameAttribute,
|
||||||
|
GroupAttribute = gateway.GroupAttribute,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests.Galaxy;
|
|||||||
public sealed class GalaxyRepositoryLiveTests
|
public sealed class GalaxyRepositoryLiveTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that the Galaxy Repository can establish a live connection to the ZB database.</summary>
|
/// <summary>Verifies that the Galaxy Repository can establish a live connection to the ZB database.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveGalaxyRepositoryFact]
|
[LiveGalaxyRepositoryFact]
|
||||||
public async Task TestConnection_AgainstZb_Succeeds()
|
public async Task TestConnection_AgainstZb_Succeeds()
|
||||||
{
|
{
|
||||||
@@ -18,6 +19,7 @@ public sealed class GalaxyRepositoryLiveTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that the last deploy time can be retrieved from the ZB database.</summary>
|
/// <summary>Verifies that the last deploy time can be retrieved from the ZB database.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveGalaxyRepositoryFact]
|
[LiveGalaxyRepositoryFact]
|
||||||
public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp()
|
public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp()
|
||||||
{
|
{
|
||||||
@@ -29,6 +31,7 @@ public sealed class GalaxyRepositoryLiveTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that the hierarchy can be retrieved from the ZB database.</summary>
|
/// <summary>Verifies that the hierarchy can be retrieved from the ZB database.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveGalaxyRepositoryFact]
|
[LiveGalaxyRepositoryFact]
|
||||||
public async Task GetHierarchy_AgainstZb_ReturnsObjects()
|
public async Task GetHierarchy_AgainstZb_ReturnsObjects()
|
||||||
{
|
{
|
||||||
@@ -46,6 +49,7 @@ public sealed class GalaxyRepositoryLiveTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that object attributes can be retrieved from the ZB database.</summary>
|
/// <summary>Verifies that object attributes can be retrieved from the ZB database.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveGalaxyRepositoryFact]
|
[LiveGalaxyRepositoryFact]
|
||||||
public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute()
|
public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess.
|
/// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveMxAccessFact]
|
[LiveMxAccessFact]
|
||||||
public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses()
|
public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses()
|
||||||
{
|
{
|
||||||
@@ -119,6 +120,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// and that the worker emits a matching <see cref="MxEventFamily.OnWriteComplete"/> event
|
/// and that the worker emits a matching <see cref="MxEventFamily.OnWriteComplete"/> event
|
||||||
/// — the proof of round-trip the cross-language client e2e runner relies on.
|
/// — the proof of round-trip the cross-language client e2e runner relies on.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveMxAccessFact]
|
[LiveMxAccessFact]
|
||||||
public async Task GatewaySession_WithLiveWorker_WritesValueToAdvisedItem()
|
public async Task GatewaySession_WithLiveWorker_WritesValueToAdvisedItem()
|
||||||
{
|
{
|
||||||
@@ -235,6 +237,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// Verifies that an AddItem against an invalid server handle surfaces the MXAccess failure
|
/// Verifies that an AddItem against an invalid server handle surfaces the MXAccess failure
|
||||||
/// without faulting the gateway transport, exercising the invalid-handle parity path.
|
/// without faulting the gateway transport, exercising the invalid-handle parity path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveMxAccessFact]
|
[LiveMxAccessFact]
|
||||||
public async Task GatewaySession_WithLiveWorker_InvalidHandleCommand_SurfacesFailureWithoutTransportFault()
|
public async Task GatewaySession_WithLiveWorker_InvalidHandleCommand_SurfacesFailureWithoutTransportFault()
|
||||||
{
|
{
|
||||||
@@ -293,6 +296,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// OnDataChange events for the un-advised item. Exercises the lifecycle-ordering
|
/// OnDataChange events for the un-advised item. Exercises the lifecycle-ordering
|
||||||
/// parity CLAUDE.md singles out as a "do not synthesize" rule.
|
/// parity CLAUDE.md singles out as a "do not synthesize" rule.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveMxAccessFact]
|
[LiveMxAccessFact]
|
||||||
public async Task GatewaySession_WithLiveWorker_UnadviseRemoveItemUnregister_TeardownOrderingParity()
|
public async Task GatewaySession_WithLiveWorker_UnadviseRemoveItemUnregister_TeardownOrderingParity()
|
||||||
{
|
{
|
||||||
@@ -437,6 +441,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// parity surface the gateway must not "fix" — the test asserts the reply kind and
|
/// parity surface the gateway must not "fix" — the test asserts the reply kind and
|
||||||
/// protocol status, not a fabricated outcome.
|
/// protocol status, not a fabricated outcome.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveMxAccessFact]
|
[LiveMxAccessFact]
|
||||||
public async Task GatewaySession_WithLiveWorker_WriteSecured_AuthenticatedRoundTripParity()
|
public async Task GatewaySession_WithLiveWorker_WriteSecured_AuthenticatedRoundTripParity()
|
||||||
{
|
{
|
||||||
@@ -568,6 +573,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// must observe the abnormal exit, transition the session, and surface a non-empty
|
/// must observe the abnormal exit, transition the session, and surface a non-empty
|
||||||
/// fault description rather than hanging or crashing.
|
/// fault description rather than hanging or crashing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveMxAccessFact]
|
[LiveMxAccessFact]
|
||||||
public async Task GatewaySession_WithLiveWorker_AbnormalWorkerExit_MarksSessionFaulted()
|
public async Task GatewaySession_WithLiveWorker_AbnormalWorkerExit_MarksSessionFaulted()
|
||||||
{
|
{
|
||||||
@@ -1114,6 +1120,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sessionId">The session identifier.</param>
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
/// <param name="session">The session if found; otherwise null.</param>
|
/// <param name="session">The session if found; otherwise null.</param>
|
||||||
|
/// <returns>True if the session was found; otherwise false.</returns>
|
||||||
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
|
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
|
||||||
{
|
{
|
||||||
return _registry.TryGet(sessionId, out session);
|
return _registry.TryGet(sessionId, out session);
|
||||||
@@ -1122,6 +1129,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Disposes the fixture resources and closes all sessions.
|
/// Disposes the fixture resources and closes all sessions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous dispose operation.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
foreach (GatewaySession session in _registry.Snapshot())
|
foreach (GatewaySession session in _registry.Snapshot())
|
||||||
@@ -1192,6 +1200,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// Records the message and signals any pending waiter.
|
/// Records the message and signals any pending waiter.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">The message to write.</param>
|
/// <param name="message">The message to write.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public Task WriteAsync(T message)
|
public Task WriteAsync(T message)
|
||||||
{
|
{
|
||||||
lock (syncRoot)
|
lock (syncRoot)
|
||||||
@@ -1374,7 +1383,9 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
return workerProcess;
|
return workerProcess;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Waits for all recorded worker processes to exit within the specified timeout.</summary>
|
||||||
|
/// <param name="timeout">Maximum time to wait for each process to exit.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task WaitForProcessesAsync(TimeSpan timeout)
|
public async Task WaitForProcessesAsync(TimeSpan timeout)
|
||||||
{
|
{
|
||||||
foreach (TestWorkerProcess process in processes)
|
foreach (TestWorkerProcess process in processes)
|
||||||
@@ -1454,7 +1465,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
process.Kill(entireProcessTree);
|
process.Kill(entireProcessTree);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Releases the wrapped process resources.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
process.Dispose();
|
process.Dispose();
|
||||||
@@ -1466,13 +1477,15 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider
|
private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <summary>Creates a logger that writes to the test output helper for the given category.</summary>
|
||||||
|
/// <param name="categoryName">The logger category name.</param>
|
||||||
|
/// <returns>A logger that forwards to the test output helper.</returns>
|
||||||
public ILogger CreateLogger(string categoryName)
|
public ILogger CreateLogger(string categoryName)
|
||||||
{
|
{
|
||||||
return new TestOutputLogger(output, categoryName);
|
return new TestOutputLogger(output, categoryName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Releases resources held by the provider (no-op for this test double).</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -1485,20 +1498,31 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
ITestOutputHelper output,
|
ITestOutputHelper output,
|
||||||
string categoryName) : ILogger
|
string categoryName) : ILogger
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <summary>Begins a log scope; returns null as this test logger does not support scopes.</summary>
|
||||||
|
/// <param name="state">The state object for the scope.</param>
|
||||||
|
/// <typeparam name="TState">The type of the state object.</typeparam>
|
||||||
|
/// <returns>Always null.</returns>
|
||||||
public IDisposable? BeginScope<TState>(TState state)
|
public IDisposable? BeginScope<TState>(TState state)
|
||||||
where TState : notnull
|
where TState : notnull
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Returns true for log levels at or above <see cref="LogLevel.Information"/>.</summary>
|
||||||
|
/// <param name="logLevel">The log level to check.</param>
|
||||||
|
/// <returns>True if the log level is enabled.</returns>
|
||||||
public bool IsEnabled(LogLevel logLevel)
|
public bool IsEnabled(LogLevel logLevel)
|
||||||
{
|
{
|
||||||
return logLevel >= LogLevel.Information;
|
return logLevel >= LogLevel.Information;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Writes a log entry to the test output helper.</summary>
|
||||||
|
/// <param name="logLevel">The log level.</param>
|
||||||
|
/// <param name="eventId">The event identifier.</param>
|
||||||
|
/// <param name="state">The state object to log.</param>
|
||||||
|
/// <param name="exception">Optional exception associated with the log entry.</param>
|
||||||
|
/// <param name="formatter">Function to format the state and exception into a string.</param>
|
||||||
|
/// <typeparam name="TState">The type of the state object.</typeparam>
|
||||||
public void Log<TState>(
|
public void Log<TState>(
|
||||||
LogLevel logLevel,
|
LogLevel logLevel,
|
||||||
EventId eventId,
|
EventId eventId,
|
||||||
|
|||||||
@@ -688,6 +688,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
|
|
||||||
/// <summary>Determines whether the alarm reference matches this subscriber's filter.</summary>
|
/// <summary>Determines whether the alarm reference matches this subscriber's filter.</summary>
|
||||||
/// <param name="reference">The alarm reference to match.</param>
|
/// <param name="reference">The alarm reference to match.</param>
|
||||||
|
/// <returns>True if the reference starts with this subscriber's prefix or no prefix is set.</returns>
|
||||||
public bool Matches(string reference)
|
public bool Matches(string reference)
|
||||||
{
|
{
|
||||||
return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal);
|
return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public interface IGatewayAlarmService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param>
|
/// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param>
|
||||||
/// <param name="cancellationToken">Token that ends the subscription.</param>
|
/// <param name="cancellationToken">Token that ends the subscription.</param>
|
||||||
|
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||||
IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
|
IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
|
||||||
string? alarmFilterPrefix,
|
string? alarmFilterPrefix,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
@@ -57,6 +58,7 @@ public interface IGatewayAlarmService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">The acknowledge request.</param>
|
/// <param name="request">The acknowledge request.</param>
|
||||||
/// <param name="cancellationToken">Token to cancel the call.</param>
|
/// <param name="cancellationToken">Token to cancel the call.</param>
|
||||||
|
/// <returns>A task that resolves to the acknowledge reply.</returns>
|
||||||
Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
||||||
AcknowledgeAlarmRequest request,
|
AcknowledgeAlarmRequest request,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ public sealed record EffectiveLdapConfiguration(
|
|||||||
bool Enabled,
|
bool Enabled,
|
||||||
string Server,
|
string Server,
|
||||||
int Port,
|
int Port,
|
||||||
bool UseTls,
|
string Transport,
|
||||||
bool AllowInsecureLdap,
|
bool AllowInsecure,
|
||||||
string SearchBase,
|
string SearchBase,
|
||||||
string ServiceAccountDn,
|
string ServiceAccountDn,
|
||||||
string ServiceAccountPassword,
|
string ServiceAccountPassword,
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
|||||||
Enabled: value.Ldap.Enabled,
|
Enabled: value.Ldap.Enabled,
|
||||||
Server: value.Ldap.Server,
|
Server: value.Ldap.Server,
|
||||||
Port: value.Ldap.Port,
|
Port: value.Ldap.Port,
|
||||||
UseTls: value.Ldap.UseTls,
|
Transport: value.Ldap.Transport.ToString(),
|
||||||
AllowInsecureLdap: value.Ldap.AllowInsecureLdap,
|
AllowInsecure: value.Ldap.AllowInsecure,
|
||||||
SearchBase: value.Ldap.SearchBase,
|
SearchBase: value.Ldap.SearchBase,
|
||||||
ServiceAccountDn: value.Ldap.ServiceAccountDn,
|
ServiceAccountDn: value.Ldap.ServiceAccountDn,
|
||||||
ServiceAccountPassword: RedactedValue,
|
ServiceAccountPassword: RedactedValue,
|
||||||
|
|||||||
+7
-7
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using ZB.MOM.WW.Configuration;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
@@ -6,15 +7,14 @@ public static class GatewayConfigurationServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
/// <summary>Registers gateway configuration services in the dependency injection container.</summary>
|
/// <summary>Registers gateway configuration services in the dependency injection container.</summary>
|
||||||
/// <param name="services">The service collection.</param>
|
/// <param name="services">The service collection.</param>
|
||||||
|
/// <param name="configuration">The configuration to bind gateway options from.</param>
|
||||||
/// <returns>The service collection for chaining.</returns>
|
/// <returns>The service collection for chaining.</returns>
|
||||||
public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services)
|
public static IServiceCollection AddGatewayConfiguration(
|
||||||
|
this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services
|
services.AddValidatedOptions<GatewayOptions, GatewayOptionsValidator>(
|
||||||
.AddOptions<GatewayOptions>()
|
configuration, GatewayOptions.SectionName);
|
||||||
.BindConfiguration(GatewayOptions.SectionName)
|
|
||||||
.ValidateOnStart();
|
|
||||||
|
|
||||||
services.AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>();
|
|
||||||
services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>();
|
services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -1,43 +1,33 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
using ZB.MOM.WW.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts;
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOptions>
|
||||||
{
|
{
|
||||||
private const int MinimumMaxMessageBytes = 1024;
|
private const int MinimumMaxMessageBytes = 1024;
|
||||||
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Validates gateway configuration options.
|
protected override void Validate(ValidationBuilder builder, GatewayOptions options)
|
||||||
/// </summary>
|
|
||||||
/// <param name="name">Options name.</param>
|
|
||||||
/// <param name="options">Gateway options to validate.</param>
|
|
||||||
/// <returns>Validation result.</returns>
|
|
||||||
public ValidateOptionsResult Validate(string? name, GatewayOptions options)
|
|
||||||
{
|
{
|
||||||
List<string> failures = [];
|
ValidateAuthentication(options.Authentication, builder);
|
||||||
|
ValidateLdap(options.Ldap, builder);
|
||||||
ValidateAuthentication(options.Authentication, failures);
|
ValidateWorker(options.Worker, builder);
|
||||||
ValidateLdap(options.Ldap, failures);
|
ValidateSessions(options.Sessions, builder);
|
||||||
ValidateWorker(options.Worker, failures);
|
ValidateEvents(options.Events, builder);
|
||||||
ValidateSessions(options.Sessions, failures);
|
ValidateDashboard(options.Dashboard, builder);
|
||||||
ValidateEvents(options.Events, failures);
|
ValidateProtocol(options.Protocol, builder);
|
||||||
ValidateDashboard(options.Dashboard, failures);
|
ValidateAlarms(options.Alarms, builder);
|
||||||
ValidateProtocol(options.Protocol, failures);
|
ValidateTls(options.Tls, builder);
|
||||||
ValidateAlarms(options.Alarms, failures);
|
|
||||||
ValidateTls(options.Tls, failures);
|
|
||||||
|
|
||||||
return failures.Count == 0
|
|
||||||
? ValidateOptionsResult.Success
|
|
||||||
: ValidateOptionsResult.Fail(failures);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateAuthentication(AuthenticationOptions options, List<string> failures)
|
private static void ValidateAuthentication(AuthenticationOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!Enum.IsDefined(options.Mode))
|
if (!Enum.IsDefined(options.Mode))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
|
builder.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,67 +36,67 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.SqlitePath,
|
options.SqlitePath,
|
||||||
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
|
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.SqlitePath,
|
options.SqlitePath,
|
||||||
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.PepperSecretName,
|
options.PepperSecretName,
|
||||||
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
||||||
failures);
|
builder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateLdap(LdapOptions options, List<string> failures)
|
private static void ValidateLdap(LdapOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!options.Enabled)
|
if (!options.Enabled)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", failures);
|
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", builder);
|
||||||
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", failures);
|
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.ServiceAccountDn,
|
options.ServiceAccountDn,
|
||||||
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
|
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.ServiceAccountPassword,
|
options.ServiceAccountPassword,
|
||||||
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
|
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.UserNameAttribute,
|
options.UserNameAttribute,
|
||||||
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
|
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.DisplayNameAttribute,
|
options.DisplayNameAttribute,
|
||||||
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
|
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.GroupAttribute,
|
options.GroupAttribute,
|
||||||
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
|
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures);
|
builder.Port(options.Port, "MxGateway:Ldap:Port");
|
||||||
|
|
||||||
if (!options.UseTls && !options.AllowInsecureLdap)
|
if (options.Transport == LdapTransport.None && !options.AllowInsecure)
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.");
|
builder.Add("MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateWorker(WorkerOptions options, List<string> failures)
|
private static void ValidateWorker(WorkerOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures);
|
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", builder);
|
||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.ExecutablePath,
|
options.ExecutablePath,
|
||||||
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
|
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
|
if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
|
||||||
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
|
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
|
builder.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
|
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
|
||||||
@@ -114,94 +104,94 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.WorkingDirectory,
|
options.WorkingDirectory,
|
||||||
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
|
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Enum.IsDefined(options.RequiredArchitecture))
|
if (!Enum.IsDefined(options.RequiredArchitecture))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
|
builder.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
|
||||||
}
|
}
|
||||||
|
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.StartupTimeoutSeconds,
|
options.StartupTimeoutSeconds,
|
||||||
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
|
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.StartupProbeRetryAttempts,
|
options.StartupProbeRetryAttempts,
|
||||||
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
|
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.StartupProbeRetryDelayMilliseconds,
|
options.StartupProbeRetryDelayMilliseconds,
|
||||||
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
|
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.PipeConnectAttemptTimeoutMilliseconds,
|
options.PipeConnectAttemptTimeoutMilliseconds,
|
||||||
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
|
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.ShutdownTimeoutSeconds,
|
options.ShutdownTimeoutSeconds,
|
||||||
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.HeartbeatIntervalSeconds,
|
options.HeartbeatIntervalSeconds,
|
||||||
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
|
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.HeartbeatGraceSeconds,
|
options.HeartbeatGraceSeconds,
|
||||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
|
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
|
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
|
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateSessions(SessionOptions options, List<string> failures)
|
private static void ValidateSessions(SessionOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.DefaultCommandTimeoutSeconds,
|
options.DefaultCommandTimeoutSeconds,
|
||||||
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
|
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", failures);
|
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.MaxPendingCommandsPerSession,
|
options.MaxPendingCommandsPerSession,
|
||||||
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
|
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.DefaultLeaseSeconds,
|
options.DefaultLeaseSeconds,
|
||||||
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
|
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.LeaseSweepIntervalSeconds,
|
options.LeaseSweepIntervalSeconds,
|
||||||
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
|
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
if (options.AllowMultipleEventSubscribers)
|
if (options.AllowMultipleEventSubscribers)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
"MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented.");
|
"MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateEvents(EventOptions options, List<string> failures)
|
private static void ValidateEvents(EventOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", failures);
|
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", builder);
|
||||||
|
|
||||||
if (!Enum.IsDefined(options.BackpressurePolicy))
|
if (!Enum.IsDefined(options.BackpressurePolicy))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
|
builder.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateDashboard(DashboardOptions options, List<string> failures)
|
private static void ValidateDashboard(DashboardOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
// GroupToRole shape is validated even when the dashboard is disabled so
|
// GroupToRole shape is validated even when the dashboard is disabled so
|
||||||
// misconfiguration surfaces at startup; emptiness is allowed, with the
|
// misconfiguration surfaces at startup; emptiness is allowed, with the
|
||||||
@@ -212,13 +202,13 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(entry.Key))
|
if (string.IsNullOrWhiteSpace(entry.Key))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank.");
|
builder.Add("MxGateway:Dashboard:GroupToRole keys (LDAP group names) must be non-blank.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal)
|
if (!string.Equals(entry.Value, Dashboard.DashboardRoles.Admin, StringComparison.Ordinal)
|
||||||
&& !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal))
|
&& !string.Equals(entry.Value, Dashboard.DashboardRoles.Viewer, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'.");
|
$"MxGateway:Dashboard:GroupToRole['{entry.Key}'] must be '{Dashboard.DashboardRoles.Admin}' or '{Dashboard.DashboardRoles.Viewer}'.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,18 +216,18 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfNotPositive(
|
AddIfNotPositive(
|
||||||
options.SnapshotIntervalMilliseconds,
|
options.SnapshotIntervalMilliseconds,
|
||||||
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
|
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNegative(
|
AddIfNegative(
|
||||||
options.RecentFaultLimit,
|
options.RecentFaultLimit,
|
||||||
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
|
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
|
||||||
failures);
|
builder);
|
||||||
AddIfNegative(
|
AddIfNegative(
|
||||||
options.RecentSessionLimit,
|
options.RecentSessionLimit,
|
||||||
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
|
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
|
||||||
failures);
|
builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateAlarms(AlarmsOptions options, List<string> failures)
|
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!options.Enabled)
|
if (!options.Enabled)
|
||||||
{
|
{
|
||||||
@@ -251,14 +241,14 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
||||||
&& string.IsNullOrWhiteSpace(options.DefaultArea))
|
&& string.IsNullOrWhiteSpace(options.DefaultArea))
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
"MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true.");
|
"MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
||||||
&& !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal))
|
&& !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,11 +256,11 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
private const int MinimumCertValidityYears = 1;
|
private const int MinimumCertValidityYears = 1;
|
||||||
private const int MaximumCertValidityYears = 100;
|
private const int MaximumCertValidityYears = 100;
|
||||||
|
|
||||||
private static void ValidateTls(TlsOptions options, List<string> failures)
|
private static void ValidateTls(TlsOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears)
|
if (options.ValidityYears is < MinimumCertValidityYears or > MaximumCertValidityYears)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}.");
|
$"MxGateway:Tls:ValidityYears must be between {MinimumCertValidityYears} and {MaximumCertValidityYears}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,61 +268,52 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
AddIfBlank(
|
AddIfBlank(
|
||||||
options.SelfSignedCertPath,
|
options.SelfSignedCertPath,
|
||||||
"MxGateway:Tls:SelfSignedCertPath must not be blank.",
|
"MxGateway:Tls:SelfSignedCertPath must not be blank.",
|
||||||
failures);
|
builder);
|
||||||
AddIfInvalidPath(
|
AddIfInvalidPath(
|
||||||
options.SelfSignedCertPath,
|
options.SelfSignedCertPath,
|
||||||
"MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
|
"MxGateway:Tls:SelfSignedCertPath must be a valid filesystem path.",
|
||||||
failures);
|
builder);
|
||||||
|
|
||||||
foreach (string dns in options.AdditionalDnsNames)
|
foreach (string dns in options.AdditionalDnsNames)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(dns))
|
if (string.IsNullOrWhiteSpace(dns))
|
||||||
{
|
{
|
||||||
failures.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank.");
|
builder.Add("MxGateway:Tls:AdditionalDnsNames entries must be non-blank.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateProtocol(ProtocolOptions options, List<string> failures)
|
private static void ValidateProtocol(ProtocolOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||||
{
|
{
|
||||||
failures.Add(
|
builder.Add(
|
||||||
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfBlank(string? value, string message, List<string> failures)
|
private static void AddIfBlank(string? value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
builder.RequireThat(!string.IsNullOrWhiteSpace(value), message);
|
||||||
{
|
|
||||||
failures.Add(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfNotPositive(int value, string message, List<string> failures)
|
private static void AddIfNotPositive(int value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (value <= 0)
|
builder.RequireThat(value > 0, message);
|
||||||
{
|
|
||||||
failures.Add(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfNegative(int value, string message, List<string> failures)
|
private static void AddIfNegative(int value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (value < 0)
|
builder.RequireThat(value >= 0, message);
|
||||||
{
|
|
||||||
failures.Add(message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddIfInvalidPath(string? value, string message, List<string> failures)
|
private static void AddIfInvalidPath(string? value, string message, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value))
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
{
|
{
|
||||||
@@ -345,15 +326,19 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
|||||||
}
|
}
|
||||||
catch (ArgumentException)
|
catch (ArgumentException)
|
||||||
{
|
{
|
||||||
failures.Add(message);
|
builder.Add(message);
|
||||||
}
|
}
|
||||||
catch (NotSupportedException)
|
catch (NotSupportedException)
|
||||||
{
|
{
|
||||||
failures.Add(message);
|
builder.Add(message);
|
||||||
}
|
}
|
||||||
catch (PathTooLongException)
|
catch (PathTooLongException)
|
||||||
{
|
{
|
||||||
failures.Add(message);
|
builder.Add(message);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
builder.Add(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ public interface IGatewayConfigurationProvider
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the validated and effective gateway configuration.
|
/// Returns the validated and effective gateway configuration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>The <see cref="EffectiveGatewayConfiguration"/> with validated defaults applied.</returns>
|
||||||
EffectiveGatewayConfiguration GetEffectiveConfiguration();
|
EffectiveGatewayConfiguration GetEffectiveConfiguration();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gateway-side view of the <c>MxGateway:Ldap</c> section. This is a SHADOW of the
|
||||||
|
/// shared <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions"/> type and is NOT
|
||||||
|
/// used to perform LDAP authentication at runtime — runtime bind/search is done by the
|
||||||
|
/// shared <c>ZB.MOM.WW.Auth.Ldap</c> provider, whose options are bound directly from the
|
||||||
|
/// same <c>MxGateway:Ldap</c> section by <c>AddZbLdapAuth</c> (see
|
||||||
|
/// <see cref="ZB.MOM.WW.MxGateway.Server.Dashboard.DashboardServiceCollectionExtensions"/>).
|
||||||
|
/// <para>
|
||||||
|
/// This shadow exists for three things only: (1) startup validation via
|
||||||
|
/// <see cref="GatewayOptionsValidator"/>; (2) the redacted effective-config display
|
||||||
|
/// (<see cref="EffectiveLdapConfiguration"/> / <see cref="GatewayConfigurationProvider"/>);
|
||||||
|
/// and (3) it is the single home of the gateway's dev/default LDAP values, which the
|
||||||
|
/// integration live-test helper copies onto the shared options.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Review C2 — DRIFT WARNING: this class MUST stay field-compatible with the shared
|
||||||
|
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions"/> so the one config section
|
||||||
|
/// binds cleanly onto both. The two are intentionally NOT merged because their defaults
|
||||||
|
/// differ on purpose: this shadow ships dev-friendly defaults (plaintext localhost,
|
||||||
|
/// <c>AllowInsecure=true</c>, populated <c>SearchBase</c>/<c>ServiceAccount*</c>), whereas
|
||||||
|
/// the shared type is secure-by-default (<c>Transport=Ldaps</c>, <c>AllowInsecure=false</c>,
|
||||||
|
/// empty DN fields). If you add/rename/remove a field on the shared type, mirror it here
|
||||||
|
/// (and in the validator + effective-config) so the section keeps binding to both.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
public sealed class LdapOptions
|
public sealed class LdapOptions
|
||||||
{
|
{
|
||||||
/// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary>
|
/// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary>
|
||||||
@@ -11,17 +38,24 @@ public sealed class LdapOptions
|
|||||||
/// <summary>Gets the LDAP server port.</summary>
|
/// <summary>Gets the LDAP server port.</summary>
|
||||||
public int Port { get; init; } = 3893;
|
public int Port { get; init; } = 3893;
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether TLS is required for the connection.</summary>
|
/// <summary>
|
||||||
public bool UseTls { get; init; }
|
/// Gets the transport/TLS mode for the LDAP connection. Replaces the former
|
||||||
|
/// boolean <c>UseTls</c> (true ≈ <see cref="LdapTransport.Ldaps"/>, false =
|
||||||
|
/// <see cref="LdapTransport.None"/>). <see cref="LdapTransport.StartTls"/> upgrades
|
||||||
|
/// a plaintext connection to TLS. Matches the shared
|
||||||
|
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions.Transport"/> field so the
|
||||||
|
/// <c>MxGateway:Ldap</c> section binds straight onto the shared options.
|
||||||
|
/// </summary>
|
||||||
|
public LdapTransport Transport { get; init; } = LdapTransport.None;
|
||||||
|
|
||||||
/// <summary>Gets a value indicating whether insecure LDAP connections are allowed.</summary>
|
/// <summary>Gets a value indicating whether insecure (plaintext) LDAP connections are allowed.</summary>
|
||||||
public bool AllowInsecureLdap { get; init; } = true;
|
public bool AllowInsecure { get; init; } = true;
|
||||||
|
|
||||||
/// <summary>Gets the LDAP search base distinguished name.</summary>
|
/// <summary>Gets the LDAP search base distinguished name.</summary>
|
||||||
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
|
public string SearchBase { get; init; } = "dc=zb,dc=local";
|
||||||
|
|
||||||
/// <summary>Gets the service account distinguished name.</summary>
|
/// <summary>Gets the service account distinguished name.</summary>
|
||||||
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=lmxopcua,dc=local";
|
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=zb,dc=local";
|
||||||
|
|
||||||
/// <summary>Gets the service account password.</summary>
|
/// <summary>Gets the service account password.</summary>
|
||||||
public string ServiceAccountPassword { get; init; } = "serviceaccount123";
|
public string ServiceAccountPassword { get; init; } = "serviceaccount123";
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||||
<link rel="stylesheet" href="/css/theme.css" />
|
<ThemeHead />
|
||||||
<link rel="stylesheet" href="/css/site.css" />
|
<link rel="stylesheet" href="/css/site.css" />
|
||||||
<HeadOutlet @rendermode="InteractiveServer" />
|
<HeadOutlet @rendermode="InteractiveServer" />
|
||||||
</head>
|
</head>
|
||||||
<body class="dashboard-body">
|
<body class="dashboard-body">
|
||||||
<Routes @rendermode="InteractiveServer" />
|
<Routes @rendermode="InteractiveServer" />
|
||||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/js/nav-state.js"></script>
|
<ThemeScripts />
|
||||||
<script src="/_framework/blazor.web.js"></script>
|
<script src="/_framework/blazor.web.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
|
|||||||
await ConnectHubAsync().ConfigureAwait(false);
|
await ConnectHubAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Disposes the SignalR hub connection and suppresses finalization.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_hub is not null)
|
if (_hub is not null)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
@* Minimal layout for the login page: no side rail, no brand block. The page
|
||||||
|
renders its own centred card via the shared kit's <LoginCard>. Mirrors
|
||||||
|
OtOpcUa AdminUI's LoginLayout. *@
|
||||||
|
@Body
|
||||||
@@ -1,210 +1,40 @@
|
|||||||
@using System.Linq
|
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
|
||||||
@using Microsoft.JSInterop
|
|
||||||
@implements IDisposable
|
|
||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@inject NavigationManager Navigation
|
|
||||||
@inject IJSRuntime JS
|
|
||||||
|
|
||||||
<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">
|
@* Thin layout: delegates the side-rail chassis (hamburger, brand, responsive
|
||||||
@* Hamburger toggle: visible only on viewports <lg. Bootstrap collapse JS
|
collapse) to the shared ZB.MOM.WW.Theme <ThemeShell>. The nav is reproduced
|
||||||
lives in bootstrap.bundle.min.js (loaded in App.razor). *@
|
with the kit's NavRailSection / NavRailItem; section expand-state persistence
|
||||||
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
|
is owned by the kit's <details> + ThemeScripts (no JS interop here). *@
|
||||||
type="button"
|
<ThemeShell Product="MXAccess Gateway" Accent="#2f5fd0">
|
||||||
data-bs-toggle="collapse"
|
<Nav>
|
||||||
data-bs-target="#sidebar-collapse"
|
<NavRailItem Href="/" Text="Dashboard" Match="NavLinkMatch.All" />
|
||||||
aria-controls="sidebar-collapse"
|
<NavRailSection Title="Runtime" Key="runtime">
|
||||||
aria-expanded="false"
|
<NavRailItem Href="/sessions" Text="Sessions" />
|
||||||
aria-label="Toggle navigation">
|
<NavRailItem Href="/workers" Text="Workers" />
|
||||||
☰
|
<NavRailItem Href="/events" Text="Events" />
|
||||||
</button>
|
<NavRailItem Href="/alarms" Text="Alarms" />
|
||||||
|
</NavRailSection>
|
||||||
<div class="collapse d-lg-block" id="sidebar-collapse">
|
<NavRailSection Title="Galaxy" Key="galaxy">
|
||||||
<nav class="sidebar d-flex flex-column">
|
<NavRailItem Href="/galaxy" Text="Repository" />
|
||||||
<a class="brand" href="/"><span class="mark">▮</span> MXAccess Gateway</a>
|
<NavRailItem Href="/browse" Text="Browse" />
|
||||||
|
</NavRailSection>
|
||||||
<div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
|
<NavRailSection Title="Admin" Key="admin">
|
||||||
<ul class="nav flex-column">
|
<NavRailItem Href="/apikeys" Text="API Keys" />
|
||||||
<li class="nav-item">
|
<NavRailItem Href="/settings" Text="Settings" />
|
||||||
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
|
</NavRailSection>
|
||||||
</li>
|
</Nav>
|
||||||
|
<RailFooter>
|
||||||
<NavSection Title="Runtime"
|
<AuthorizeView>
|
||||||
Expanded="@_expanded.Contains("runtime")"
|
<Authorized Context="authState">
|
||||||
OnToggle="@(() => ToggleAsync("runtime"))">
|
<span class="rail-user">@authState.User.Identity?.Name</span>
|
||||||
<li class="nav-item">
|
<form method="post" action="/logout" data-enhance="false">
|
||||||
<NavLink class="nav-link" href="/sessions" Match="NavLinkMatch.Prefix">Sessions</NavLink>
|
<AntiforgeryToken />
|
||||||
</li>
|
<button class="rail-btn" type="submit">Sign Out</button>
|
||||||
<li class="nav-item">
|
</form>
|
||||||
<NavLink class="nav-link" href="/workers" Match="NavLinkMatch.Prefix">Workers</NavLink>
|
</Authorized>
|
||||||
</li>
|
<NotAuthorized>
|
||||||
<li class="nav-item">
|
<a class="rail-btn" href="/login">Sign In</a>
|
||||||
<NavLink class="nav-link" href="/events" Match="NavLinkMatch.Prefix">Events</NavLink>
|
</NotAuthorized>
|
||||||
</li>
|
</AuthorizeView>
|
||||||
<li class="nav-item">
|
</RailFooter>
|
||||||
<NavLink class="nav-link" href="/alarms" Match="NavLinkMatch.Prefix">Alarms</NavLink>
|
<ChildContent>@Body</ChildContent>
|
||||||
</li>
|
</ThemeShell>
|
||||||
</NavSection>
|
|
||||||
|
|
||||||
<NavSection Title="Galaxy"
|
|
||||||
Expanded="@_expanded.Contains("galaxy")"
|
|
||||||
OnToggle="@(() => ToggleAsync("galaxy"))">
|
|
||||||
<li class="nav-item">
|
|
||||||
<NavLink class="nav-link" href="/galaxy" Match="NavLinkMatch.Prefix">Repository</NavLink>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<NavLink class="nav-link" href="/browse" Match="NavLinkMatch.Prefix">Browse</NavLink>
|
|
||||||
</li>
|
|
||||||
</NavSection>
|
|
||||||
|
|
||||||
<NavSection Title="Admin"
|
|
||||||
Expanded="@_expanded.Contains("admin")"
|
|
||||||
OnToggle="@(() => ToggleAsync("admin"))">
|
|
||||||
<li class="nav-item">
|
|
||||||
<NavLink class="nav-link" href="/apikeys" Match="NavLinkMatch.Prefix">API Keys</NavLink>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<NavLink class="nav-link" href="/settings" Match="NavLinkMatch.Prefix">Settings</NavLink>
|
|
||||||
</li>
|
|
||||||
</NavSection>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AuthorizeView>
|
|
||||||
<Authorized Context="authState">
|
|
||||||
<div class="border-top px-3 py-2">
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<span class="text-body-secondary small">@authState.User.Identity?.Name</span>
|
|
||||||
<form method="post" action="/logout" data-enhance="false">
|
|
||||||
<AntiforgeryToken />
|
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm py-0 px-2">Sign Out</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Authorized>
|
|
||||||
<NotAuthorized>
|
|
||||||
<div class="border-top px-3 py-2">
|
|
||||||
<a href="/login" class="btn btn-outline-secondary btn-sm py-0 px-2 w-100">Sign In</a>
|
|
||||||
</div>
|
|
||||||
</NotAuthorized>
|
|
||||||
</AuthorizeView>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="page flex-grow-1">
|
|
||||||
@Body
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
// Sections whose collapsed/expanded state we persist. Acts as the allow-list
|
|
||||||
// when parsing the cookie so stale or attacker-supplied ids are ignored.
|
|
||||||
private static readonly string[] SectionIds = { "runtime", "galaxy", "admin" };
|
|
||||||
|
|
||||||
// The currently-expanded sections. Populated from the cookie on first
|
|
||||||
// render; mutated by ToggleAsync and by navigating into a section.
|
|
||||||
private readonly HashSet<string> _expanded = new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
protected override void OnInitialized()
|
|
||||||
{
|
|
||||||
Navigation.LocationChanged += OnLocationChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
if (!firstRender)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hydrate from the cookie. Until this completes the sidebar paints
|
|
||||||
// collapsed, matching the CentralUI behaviour.
|
|
||||||
string saved;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
saved = await JS.InvokeAsync<string>("navState.get") ?? string.Empty;
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var id in saved.Split(
|
|
||||||
',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
||||||
{
|
|
||||||
if (Array.IndexOf(SectionIds, id) >= 0)
|
|
||||||
{
|
|
||||||
_expanded.Add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The section of the page we loaded on is always expanded.
|
|
||||||
if (EnsureCurrentSectionExpanded())
|
|
||||||
{
|
|
||||||
await PersistAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
StateHasChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (EnsureCurrentSectionExpanded())
|
|
||||||
{
|
|
||||||
_ = PersistAsync();
|
|
||||||
_ = InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ToggleAsync(string id)
|
|
||||||
{
|
|
||||||
if (!_expanded.Remove(id))
|
|
||||||
{
|
|
||||||
_expanded.Add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
await PersistAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adds the current page's section to _expanded; returns true if it changed.
|
|
||||||
private bool EnsureCurrentSectionExpanded()
|
|
||||||
{
|
|
||||||
var section = CurrentSection();
|
|
||||||
return section is not null && _expanded.Add(section);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maps the current URL's first path segment to a section id, or null for
|
|
||||||
// sectionless pages (Dashboard, Login).
|
|
||||||
private string? CurrentSection()
|
|
||||||
{
|
|
||||||
var relative = Navigation.ToBaseRelativePath(Navigation.Uri);
|
|
||||||
var firstSegment = relative.Split('?', '#')[0]
|
|
||||||
.Split('/', StringSplitOptions.RemoveEmptyEntries)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
return firstSegment switch
|
|
||||||
{
|
|
||||||
"sessions" or "workers" or "events" or "alarms" => "runtime",
|
|
||||||
"galaxy" or "browse" => "galaxy",
|
|
||||||
"apikeys" or "settings" => "admin",
|
|
||||||
_ => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task PersistAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await JS.InvokeVoidAsync("navState.set", string.Join(',', _expanded));
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException)
|
|
||||||
{
|
|
||||||
// The circuit is gone — nothing to persist to.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Navigation.LocationChanged -= OnLocationChanged;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
@* A collapsible sidebar nav section. The header is a full-width button that
|
|
||||||
toggles ChildContent visibility. Pattern lifted from ScadaLink CentralUI
|
|
||||||
(Components/Layout/NavSection.razor) — see [[project-deployed-service]]. *@
|
|
||||||
|
|
||||||
<li class="nav-item">
|
|
||||||
<button type="button"
|
|
||||||
class="nav-section-toggle"
|
|
||||||
@onclick="OnToggle"
|
|
||||||
aria-expanded="@(Expanded ? "true" : "false")">
|
|
||||||
<span class="chevron" aria-hidden="true">@(Expanded ? "▾" : "▸")</span>
|
|
||||||
<span>@Title</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
@if (Expanded)
|
|
||||||
{
|
|
||||||
@ChildContent
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
/// <summary>Section label shown in the header (e.g. "Runtime").</summary>
|
|
||||||
[Parameter, EditorRequired]
|
|
||||||
public string Title { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Whether the section is expanded — its items rendered.</summary>
|
|
||||||
[Parameter]
|
|
||||||
public bool Expanded { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Raised when the header button is clicked.</summary>
|
|
||||||
[Parameter]
|
|
||||||
public EventCallback OnToggle { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The section's nav items, rendered only while expanded.</summary>
|
|
||||||
[Parameter]
|
|
||||||
public RenderFragment? ChildContent { get; set; }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@page "/login"
|
||||||
|
@layout LoginLayout
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@* Login MUST stay anonymously reachable — [AllowAnonymous] overrides the
|
||||||
|
RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies, so the
|
||||||
|
cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users.
|
||||||
|
|
||||||
|
The card is the shared kit's <LoginCard>: it renders a NATIVE static
|
||||||
|
<form method="post" action="/login"> (username/password + hidden returnUrl). A native
|
||||||
|
form submit is not a Blazor event, so it reaches the minimal-API POST /login endpoint
|
||||||
|
regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the
|
||||||
|
token that PostLoginAsync's antiforgery.ValidateRequestAsync checks. *@
|
||||||
|
@attribute [AllowAnonymous]
|
||||||
|
|
||||||
|
<LoginCard Product="MXAccess Gateway" Action="/login" ReturnUrl="@ReturnUrl" Error="@Error">
|
||||||
|
<AntiforgeryToken />
|
||||||
|
</LoginCard>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>Original protected URL the operator was bounced from; round-tripped to POST /login.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "returnUrl")]
|
||||||
|
private string? ReturnUrl { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Failure message surfaced by POST /login after a failed authentication.</summary>
|
||||||
|
[SupplyParameterFromQuery(Name = "error")]
|
||||||
|
private string? Error { get; set; }
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ else
|
|||||||
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</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 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 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 transport</th><td>@Snapshot.Configuration.Ldap.Transport</td></tr>
|
||||||
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></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 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 service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<span class="chip @CssClass">@Text</span>
|
@* Thin adapter: maps MxGateway runtime state text → kit StatusPill state.
|
||||||
|
The bespoke .chip rendering now lives in the kit; only the app's domain
|
||||||
|
text→state vocabulary remains here. Call sites (<StatusBadge Text="..."/>) unchanged. *@
|
||||||
|
<StatusPill State="MapState(Text)">@Text</StatusPill>
|
||||||
@code {
|
@code {
|
||||||
[Parameter]
|
[Parameter] public string? Text { get; set; }
|
||||||
public string? Text { get; set; }
|
|
||||||
|
|
||||||
private string CssClass => Text switch
|
private static StatusState MapState(string? text) => text switch
|
||||||
{
|
{
|
||||||
"Ready" or "Healthy" or "Active" => "chip-ok",
|
"Ready" or "Healthy" or "Active" => StatusState.Ok,
|
||||||
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "chip-warn",
|
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing"
|
||||||
"Stale" or "Degraded" => "chip-warn",
|
or "Stale" or "Degraded" => StatusState.Warn,
|
||||||
"Faulted" or "Unavailable" => "chip-bad",
|
"Faulted" or "Unavailable" => StatusState.Bad,
|
||||||
"Closed" or "Revoked" or "Unknown" => "chip-idle",
|
_ => StatusState.Idle,
|
||||||
_ => "chip-idle"
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,5 @@
|
|||||||
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Shared
|
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Shared
|
||||||
@using ZB.MOM.WW.MxGateway.Server.Security.Authorization
|
@using ZB.MOM.WW.MxGateway.Server.Security.Authorization
|
||||||
@using ZB.MOM.WW.MxGateway.Server.Workers
|
@using ZB.MOM.WW.MxGateway.Server.Workers
|
||||||
|
@using ZB.MOM.WW.Theme
|
||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public sealed class DashboardApiKeyAuthorization
|
|||||||
{
|
{
|
||||||
/// <summary>Determines whether the user can manage API keys.</summary>
|
/// <summary>Determines whether the user can manage API keys.</summary>
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
/// <param name="user">The authenticated user principal.</param>
|
||||||
|
/// <returns>True if the user is an authenticated admin; otherwise false.</returns>
|
||||||
public bool CanManage(ClaimsPrincipal user)
|
public bool CanManage(ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
if (user.Identity?.IsAuthenticated != true)
|
if (user.Identity?.IsAuthenticated != true)
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
|
|
||||||
@@ -7,24 +12,21 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|||||||
|
|
||||||
public sealed class DashboardApiKeyManagementService(
|
public sealed class DashboardApiKeyManagementService(
|
||||||
DashboardApiKeyAuthorization authorization,
|
DashboardApiKeyAuthorization authorization,
|
||||||
|
ApiKeyAdminCommands adminCommands,
|
||||||
IApiKeyAdminStore adminStore,
|
IApiKeyAdminStore adminStore,
|
||||||
IApiKeyAuditStore auditStore,
|
IAuditWriter auditWriter,
|
||||||
IApiKeySecretHasher hasher,
|
|
||||||
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
||||||
{
|
{
|
||||||
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
||||||
|
private const string PepperUnavailableMarker = "pepper unavailable";
|
||||||
|
|
||||||
/// <summary>Determines whether the user can manage API keys.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
|
||||||
public bool CanManage(ClaimsPrincipal user)
|
public bool CanManage(ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
return authorization.CanManage(user);
|
return authorization.CanManage(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates an API key asynchronously.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
|
||||||
/// <param name="request">The request payload.</param>
|
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
||||||
public async Task<DashboardApiKeyManagementResult> CreateAsync(
|
public async Task<DashboardApiKeyManagementResult> CreateAsync(
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
DashboardApiKeyManagementRequest request,
|
DashboardApiKeyManagementRequest request,
|
||||||
@@ -42,28 +44,31 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
string keyId = request.KeyId.Trim();
|
string keyId = request.KeyId.Trim();
|
||||||
string secret = ApiKeySecretGenerator.Generate();
|
|
||||||
string apiKey = FormatApiKey(keyId, secret);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await adminStore.CreateAsync(
|
// The shared command set generates the secret, hashes it with the pepper, persists the
|
||||||
new ApiKeyCreateRequest(
|
// record and assembles the mxgw_<id>_<secret> token (shown once). It also appends its own
|
||||||
KeyId: keyId,
|
// "create-key" audit entry (now canonicalized through the IApiKeyAuditStore->IAuditWriter
|
||||||
KeyPrefix: $"mxgw_{keyId}",
|
// adapter); the dashboard layers a richer "dashboard-create-key" canonical AuditEvent
|
||||||
SecretHash: hasher.HashSecret(secret),
|
// (Target + CorrelationId + remote address) on top via IAuditWriter to preserve the
|
||||||
DisplayName: request.DisplayName.Trim(),
|
// dashboard audit vocabulary — both rows land in the canonical audit_event store.
|
||||||
Scopes: request.Scopes,
|
CreateKeyResult created = await adminCommands.CreateKeyAsync(
|
||||||
Constraints: request.Constraints,
|
keyId,
|
||||||
CreatedUtc: DateTimeOffset.UtcNow),
|
request.DisplayName.Trim(),
|
||||||
|
request.Scopes,
|
||||||
|
ApiKeyConstraintSerializer.Serialize(request.Constraints),
|
||||||
|
RemoteAddress(),
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
|
await WriteDashboardAuditAsync(user, keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
return DashboardApiKeyManagementResult.Success("API key created. Copy the key now; it will not be shown again.", apiKey);
|
return DashboardApiKeyManagementResult.Success(
|
||||||
|
"API key created. Copy the key now; it will not be shown again.",
|
||||||
|
created.Token);
|
||||||
}
|
}
|
||||||
catch (ApiKeyPepperUnavailableException)
|
catch (InvalidOperationException exception) when (IsPepperUnavailable(exception))
|
||||||
{
|
{
|
||||||
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
||||||
}
|
}
|
||||||
@@ -73,10 +78,7 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Revokes an API key asynchronously.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
|
||||||
/// <param name="keyId">The API key identifier.</param>
|
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
||||||
public async Task<DashboardApiKeyManagementResult> RevokeAsync(
|
public async Task<DashboardApiKeyManagementResult> RevokeAsync(
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
string keyId,
|
string keyId,
|
||||||
@@ -94,26 +96,24 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
string normalizedKeyId = keyId.Trim();
|
string normalizedKeyId = keyId.Trim();
|
||||||
bool revoked = await adminStore
|
KeyActionResult result = await adminCommands
|
||||||
.RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken)
|
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(
|
await WriteDashboardAuditAsync(
|
||||||
|
user,
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
"dashboard-revoke-key",
|
"dashboard-revoke-key",
|
||||||
revoked ? "revoked" : "not-found-or-already-revoked",
|
result.Succeeded ? "revoked" : "not-found-or-already-revoked",
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return revoked
|
return result.Succeeded
|
||||||
? DashboardApiKeyManagementResult.Success("API key revoked.")
|
? DashboardApiKeyManagementResult.Success("API key revoked.")
|
||||||
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
|
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Rotates an API key secret asynchronously.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
|
||||||
/// <param name="keyId">The API key identifier.</param>
|
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
||||||
public async Task<DashboardApiKeyManagementResult> RotateAsync(
|
public async Task<DashboardApiKeyManagementResult> RotateAsync(
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
string keyId,
|
string keyId,
|
||||||
@@ -131,36 +131,36 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
string normalizedKeyId = keyId.Trim();
|
string normalizedKeyId = keyId.Trim();
|
||||||
string secret = ApiKeySecretGenerator.Generate();
|
|
||||||
string apiKey = FormatApiKey(normalizedKeyId, secret);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
bool rotated = await adminStore
|
CreateKeyResult rotated = await adminCommands
|
||||||
.RotateAsync(normalizedKeyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
|
.RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(
|
bool succeeded = rotated.Token is not null;
|
||||||
|
|
||||||
|
await WriteDashboardAuditAsync(
|
||||||
|
user,
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
"dashboard-rotate-key",
|
"dashboard-rotate-key",
|
||||||
rotated ? "rotated" : "not-found",
|
succeeded ? "rotated" : "not-found",
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return rotated
|
return succeeded
|
||||||
? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey)
|
? DashboardApiKeyManagementResult.Success(
|
||||||
|
"API key rotated. Copy the key now; it will not be shown again.",
|
||||||
|
rotated.Token)
|
||||||
: DashboardApiKeyManagementResult.Fail("API key was not found.");
|
: DashboardApiKeyManagementResult.Fail("API key was not found.");
|
||||||
}
|
}
|
||||||
catch (ApiKeyPepperUnavailableException)
|
catch (InvalidOperationException exception) when (IsPepperUnavailable(exception))
|
||||||
{
|
{
|
||||||
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Deletes a revoked API key asynchronously.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
|
||||||
/// <param name="keyId">The API key identifier.</param>
|
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
||||||
public async Task<DashboardApiKeyManagementResult> DeleteAsync(
|
public async Task<DashboardApiKeyManagementResult> DeleteAsync(
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
string keyId,
|
string keyId,
|
||||||
@@ -182,7 +182,8 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
.DeleteAsync(normalizedKeyId, cancellationToken)
|
.DeleteAsync(normalizedKeyId, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(
|
await WriteDashboardAuditAsync(
|
||||||
|
user,
|
||||||
normalizedKeyId,
|
normalizedKeyId,
|
||||||
"dashboard-delete-key",
|
"dashboard-delete-key",
|
||||||
deleted ? "deleted" : "not-found-or-active",
|
deleted ? "deleted" : "not-found-or-active",
|
||||||
@@ -194,22 +195,92 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
: DashboardApiKeyManagementResult.Fail("API key was not found, or is still active. Revoke it before deleting.");
|
: DashboardApiKeyManagementResult.Fail("API key was not found, or is still active. Revoke it before deleting.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AppendAuditAsync(
|
private string? RemoteAddress() =>
|
||||||
string? keyId,
|
httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||||
string eventType,
|
|
||||||
string? details,
|
/// <summary>
|
||||||
|
/// Resolves the operator's username from the authenticated dashboard principal.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The passed <paramref name="user"/> is preferred over the ambient HTTP context because it
|
||||||
|
/// is already in scope at every call site (the callers gate on <see cref="CanManage"/> using
|
||||||
|
/// it) and is unambiguous. Falls back to <see cref="IAuditActorAccessor.CurrentActor"/> for
|
||||||
|
/// defensive coverage, then to <c>"unknown"</c> when neither is available.
|
||||||
|
/// </remarks>
|
||||||
|
private static string ResolveOperatorActor(ClaimsPrincipal user)
|
||||||
|
{
|
||||||
|
// ZbClaimTypes.Username = "zb:username" — the canonical LDAP login name.
|
||||||
|
string? username = user.FindFirstValue(ZB.MOM.WW.Auth.AspNetCore.ZbClaimTypes.Username);
|
||||||
|
if (!string.IsNullOrWhiteSpace(username))
|
||||||
|
{
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Framework fallback: Identity.Name is driven by the nameClaimType on the ClaimsIdentity
|
||||||
|
// (set to ZbClaimTypes.Name = ClaimTypes.Name by DashboardAuthenticator → display name).
|
||||||
|
string? identityName = user.Identity?.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(identityName))
|
||||||
|
{
|
||||||
|
return identityName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits the dashboard's own canonical <see cref="AuditEvent"/> for a <c>dashboard-*</c> op
|
||||||
|
/// directly through the best-effort <see cref="IAuditWriter"/> (Task 2.3 #6). This is in
|
||||||
|
/// addition to the <c>create/revoke/rotate-key</c> event that <see cref="ApiKeyAdminCommands"/>
|
||||||
|
/// emits via the canonical-forwarding <c>IApiKeyAuditStore</c> adapter — the doubled-audit
|
||||||
|
/// behaviour is preserved, both rows now land in the canonical <c>audit_event</c> store.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Phase 3 (Actor = operator principal): <c>Actor</c> is the LDAP operator who performed the
|
||||||
|
/// action (resolved from the <paramref name="user"/> principal); <c>Target</c> is the managed
|
||||||
|
/// API key id. This fixes the pre-Phase-3 semantic gap where both fields held the keyId.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task WriteDashboardAuditAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
string keyId,
|
||||||
|
string action,
|
||||||
|
string? detail,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await auditStore.AppendAsync(
|
AuditEvent auditEvent = new()
|
||||||
new ApiKeyAuditEntry(
|
{
|
||||||
KeyId: keyId,
|
EventId = Guid.NewGuid(),
|
||||||
EventType: eventType,
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(),
|
Actor = ResolveOperatorActor(user),
|
||||||
Details: details),
|
Action = action,
|
||||||
cancellationToken)
|
Outcome = AuditOutcome.Success,
|
||||||
.ConfigureAwait(false);
|
Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory,
|
||||||
|
Target = keyId,
|
||||||
|
SourceNode = RemoteAddress(),
|
||||||
|
CorrelationId = ParseCorrelationId(),
|
||||||
|
DetailsJson = WrapDetail(detail),
|
||||||
|
};
|
||||||
|
|
||||||
|
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives a correlation id from the ASP.NET Core request trace identifier when it is a
|
||||||
|
/// well-formed GUID; otherwise null (the default <c>HttpContext.TraceIdentifier</c> is the
|
||||||
|
/// connection:request form, not a GUID, so it correlates to null rather than fabricating one).
|
||||||
|
/// </summary>
|
||||||
|
private Guid? ParseCorrelationId() =>
|
||||||
|
Guid.TryParse(httpContextAccessor.HttpContext?.TraceIdentifier, out Guid correlationId)
|
||||||
|
? correlationId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string? WrapDetail(string? detail) =>
|
||||||
|
detail is null
|
||||||
|
? null
|
||||||
|
: JsonSerializer.Serialize(new Dictionary<string, string> { ["detail"] = detail });
|
||||||
|
|
||||||
|
private static bool IsPepperUnavailable(InvalidOperationException exception) =>
|
||||||
|
exception.Message.Contains(PepperUnavailableMarker, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
|
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
|
||||||
{
|
{
|
||||||
string? keyIdValidation = ValidateKeyId(request.KeyId);
|
string? keyIdValidation = ValidateKeyId(request.KeyId);
|
||||||
@@ -248,9 +319,4 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
? null
|
? null
|
||||||
: "API key id may contain only letters, numbers, periods, and hyphens.";
|
: "API key id may contain only letters, numbers, periods, and hyphens.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatApiKey(string keyId, string secret)
|
|
||||||
{
|
|
||||||
return $"mxgw_{keyId}_{secret}";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public sealed record DashboardAuthenticationResult(
|
|||||||
/// Creates a successful authentication result.
|
/// Creates a successful authentication result.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="principal">Authenticated principal.</param>
|
/// <param name="principal">Authenticated principal.</param>
|
||||||
|
/// <returns>A successful <see cref="DashboardAuthenticationResult"/> wrapping the principal.</returns>
|
||||||
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
|
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
|
||||||
{
|
{
|
||||||
return new DashboardAuthenticationResult(true, principal, null);
|
return new DashboardAuthenticationResult(true, principal, null);
|
||||||
@@ -32,6 +33,7 @@ public sealed record DashboardAuthenticationResult(
|
|||||||
/// Creates a failed authentication result.
|
/// Creates a failed authentication result.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="failureMessage">Diagnostic message describing the failure.</param>
|
/// <param name="failureMessage">Diagnostic message describing the failure.</param>
|
||||||
|
/// <returns>A failed <see cref="DashboardAuthenticationResult"/> with the given message.</returns>
|
||||||
public static DashboardAuthenticationResult Fail(string failureMessage)
|
public static DashboardAuthenticationResult Fail(string failureMessage)
|
||||||
{
|
{
|
||||||
return new DashboardAuthenticationResult(false, null, failureMessage);
|
return new DashboardAuthenticationResult(false, null, failureMessage);
|
||||||
|
|||||||
@@ -1,14 +1,26 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
using Microsoft.Extensions.Options;
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
|
||||||
using Novell.Directory.Ldap;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Authenticates interactive dashboard logins against LDAP. The bind/search
|
||||||
|
/// mechanics are delegated to the shared <see cref="ILdapAuthService"/>
|
||||||
|
/// (<c>ZB.MOM.WW.Auth.Ldap</c>), which performs bind-then-search, fails closed,
|
||||||
|
/// and never throws — returning the user's display name and LDAP groups on
|
||||||
|
/// success. This class keeps the dashboard-specific policy: groups are resolved
|
||||||
|
/// to dashboard roles via <see cref="IGroupRoleMapper{TRole}"/>, a login with no
|
||||||
|
/// matching role is denied, and the resulting <see cref="ClaimsPrincipal"/> is
|
||||||
|
/// shaped exactly as before (see <see cref="CreatePrincipal"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
|
||||||
|
/// <param name="roleMapper">Maps LDAP groups to dashboard roles (Task 1.1 seam).</param>
|
||||||
|
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
|
||||||
public sealed class DashboardAuthenticator(
|
public sealed class DashboardAuthenticator(
|
||||||
IOptions<GatewayOptions> options,
|
ILdapAuthService ldapAuthService,
|
||||||
|
IGroupRoleMapper<string> roleMapper,
|
||||||
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
||||||
{
|
{
|
||||||
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
|
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
|
||||||
@@ -19,240 +31,72 @@ public sealed class DashboardAuthenticator(
|
|||||||
string? password,
|
string? password,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
LdapOptions ldapOptions = options.Value.Ldap;
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
DashboardOptions dashboardOptions = options.Value.Dashboard;
|
|
||||||
if (!ldapOptions.Enabled
|
|
||||||
|| string.IsNullOrWhiteSpace(username)
|
|
||||||
|| string.IsNullOrWhiteSpace(password))
|
|
||||||
{
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
|
|
||||||
{
|
{
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
string normalizedUsername = username.Trim();
|
string normalizedUsername = username.Trim();
|
||||||
|
|
||||||
try
|
// The shared service owns connect/bind/search and the fail-closed contract:
|
||||||
|
// it returns Fail(Disabled) when LDAP is off, enforces TLS-or-AllowInsecure via
|
||||||
|
// its startup validator, and never throws. We only translate its outcome into a
|
||||||
|
// dashboard principal here.
|
||||||
|
LdapAuthResult ldapResult = await ldapAuthService
|
||||||
|
.AuthenticateAsync(normalizedUsername, password, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!ldapResult.Succeeded)
|
||||||
{
|
{
|
||||||
using LdapConnection connection = new();
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
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);
|
|
||||||
|
|
||||||
IReadOnlyList<string> roles = MapGroupsToRoles(groups, dashboardOptions.GroupToRole);
|
|
||||||
if (roles.Count == 0)
|
|
||||||
{
|
|
||||||
logger.LogInformation(
|
|
||||||
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
|
|
||||||
normalizedUsername);
|
|
||||||
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
|
||||||
normalizedUsername,
|
|
||||||
displayName,
|
|
||||||
groups,
|
|
||||||
roles));
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
GroupRoleMapping<string> mapping = await roleMapper
|
||||||
throw;
|
.MapAsync(ldapResult.Groups, cancellationToken)
|
||||||
}
|
.ConfigureAwait(false);
|
||||||
catch (LdapException ex)
|
|
||||||
|
IReadOnlyList<string> roles = mapping.Roles;
|
||||||
|
if (roles.Count == 0)
|
||||||
{
|
{
|
||||||
|
// Preserve the long-standing "no roles matched -> login denied" rule.
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"LDAP dashboard login rejected for user {User}: result code {ResultCode}.",
|
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
|
||||||
normalizedUsername,
|
ldapResult.Username);
|
||||||
ex.ResultCode);
|
|
||||||
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername);
|
|
||||||
|
|
||||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
||||||
}
|
ldapResult.Username,
|
||||||
}
|
ldapResult.DisplayName,
|
||||||
|
ldapResult.Groups,
|
||||||
/// <summary>Escapes special characters in LDAP filter strings.</summary>
|
roles));
|
||||||
/// <param name="value">The string value to escape.</param>
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps the user's LDAP groups to dashboard roles. A user can pick up
|
/// Builds the dashboard <see cref="ClaimsPrincipal"/> from the LDAP outcome.
|
||||||
/// multiple roles; Admin and Viewer are the only legal values. Returns
|
|
||||||
/// an empty list when no group matches (caller rejects the login).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
|
/// <param name="username">
|
||||||
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
|
/// The (trimmed) login name. Emitted as <see cref="ClaimTypes.NameIdentifier"/> (kept for
|
||||||
internal static IReadOnlyList<string> MapGroupsToRoles(
|
/// back-compat reads) and as the canonical <see cref="ZbClaimTypes.Username"/> ("zb:username").
|
||||||
IEnumerable<string> groups,
|
/// </param>
|
||||||
IReadOnlyDictionary<string, string> groupToRole)
|
/// <param name="displayName">
|
||||||
{
|
/// The user's display name. Emitted as <see cref="ZbClaimTypes.Name"/> (= <see cref="ClaimTypes.Name"/>
|
||||||
if (groupToRole.Count == 0)
|
/// so <c>Identity.Name</c> resolves) and as <see cref="ZbClaimTypes.DisplayName"/> ("zb:displayname")
|
||||||
{
|
/// for cross-app consistency.
|
||||||
return [];
|
/// </param>
|
||||||
}
|
/// <param name="groups">
|
||||||
|
/// The user's LDAP groups, as returned by <see cref="ILdapAuthService"/>. NOTE
|
||||||
HashSet<string> roles = new(StringComparer.Ordinal);
|
/// (review C1): these are <b>already-normalized short RDN names</b> (e.g.
|
||||||
foreach (string group in groups)
|
/// <c>GwAdmin</c>), not raw distinguished names. The shared
|
||||||
{
|
/// <c>ZB.MOM.WW.Auth.Ldap</c> provider strips each group DN to its first RDN
|
||||||
string normalizedGroup = group.Trim();
|
/// value before returning it, so the <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/>
|
||||||
|
/// claim carries the short name. This differs from the pre-cutover behaviour,
|
||||||
// Lookup precedence (Server-040): the full literal group string is
|
/// which surfaced the raw <c>memberOf</c> values (full DNs) on the claim; the
|
||||||
// tried first; only if that misses do we fall back to the leading
|
/// claim is informational only (no policy or UI reads its value — authorization
|
||||||
// RDN value (e.g. "GwAdmin" extracted from
|
/// is role-based), so the shape change is non-breaking for dashboard consumers.
|
||||||
// "ou=GwAdmin,ou=groups,..."). The map's comparer is
|
/// </param>
|
||||||
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g.
|
/// <param name="roles">The dashboard roles resolved from <paramref name="groups"/>.</param>
|
||||||
// "GwAdmin" and "gwadmin" both match.
|
|
||||||
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|
|
||||||
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
|
|
||||||
{
|
|
||||||
roles.Add(mapped);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [.. roles];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
|
|
||||||
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
|
||||||
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);
|
|
||||||
|
|
||||||
LdapEntry? entry = null;
|
|
||||||
while (results.HasMore())
|
|
||||||
{
|
|
||||||
LdapEntry next = results.Next();
|
|
||||||
if (entry is not null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
entry = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? ReadAttribute(LdapEntry entry, string attributeName)
|
|
||||||
{
|
|
||||||
return ReadLdapAttribute(entry, attributeName)?.StringValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(
|
private static ClaimsPrincipal CreatePrincipal(
|
||||||
string username,
|
string username,
|
||||||
string displayName,
|
string displayName,
|
||||||
@@ -261,11 +105,21 @@ public sealed class DashboardAuthenticator(
|
|||||||
{
|
{
|
||||||
List<Claim> claims =
|
List<Claim> claims =
|
||||||
[
|
[
|
||||||
|
// Keep NameIdentifier so any existing read-site that uses it continues to work.
|
||||||
new Claim(ClaimTypes.NameIdentifier, username),
|
new Claim(ClaimTypes.NameIdentifier, username),
|
||||||
new Claim(ClaimTypes.Name, displayName),
|
// Canonical login-username claim (Task 1.5).
|
||||||
|
new Claim(ZbClaimTypes.Username, username),
|
||||||
|
// ZbClaimTypes.Name == ClaimTypes.Name — drives Identity.Name resolution.
|
||||||
|
new Claim(ZbClaimTypes.Name, displayName),
|
||||||
|
// Canonical display-name claim for cross-app consistency (Task 1.5).
|
||||||
|
new Claim(ZbClaimTypes.DisplayName, displayName),
|
||||||
];
|
];
|
||||||
|
|
||||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
// ZbClaimTypes.Role == ClaimTypes.Role — drives IsInRole and [Authorize(Roles=...)].
|
||||||
|
claims.AddRange(roles.Select(role => new Claim(ZbClaimTypes.Role, role)));
|
||||||
|
// Groups are short RDN names from ILdapAuthService (see param doc above), so
|
||||||
|
// this claim value is the short group name, not the original DN.
|
||||||
|
// LdapGroupClaimType is MxGateway-specific ("mxgateway:ldap_group") — no ZbClaimType for groups.
|
||||||
claims.AddRange(groups.Select(group => new Claim(
|
claims.AddRange(groups.Select(group => new Claim(
|
||||||
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
||||||
group)));
|
group)));
|
||||||
@@ -273,8 +127,8 @@ public sealed class DashboardAuthenticator(
|
|||||||
ClaimsIdentity claimsIdentity = new(
|
ClaimsIdentity claimsIdentity = new(
|
||||||
claims,
|
claims,
|
||||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||||
ClaimTypes.Name,
|
ZbClaimTypes.Name,
|
||||||
ClaimTypes.Role);
|
ZbClaimTypes.Role);
|
||||||
|
|
||||||
return new ClaimsPrincipal(claimsIdentity);
|
return new ClaimsPrincipal(claimsIdentity);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public static class DashboardConnectionStringDisplay
|
|||||||
{
|
{
|
||||||
/// <summary>Returns a sanitized Galaxy Repository connection string for display.</summary>
|
/// <summary>Returns a sanitized Galaxy Repository connection string for display.</summary>
|
||||||
/// <param name="connectionString">The connection string to sanitize.</param>
|
/// <param name="connectionString">The connection string to sanitize.</param>
|
||||||
|
/// <returns>A sanitized connection string with credentials removed, or <c>"[invalid connection string]"</c> if parsing fails.</returns>
|
||||||
public static string GalaxyRepositoryConnectionString(string connectionString)
|
public static string GalaxyRepositoryConnectionString(string connectionString)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
+14
-59
@@ -1,7 +1,6 @@
|
|||||||
using System.Text.Encodings.Web;
|
using System.Text.Encodings.Web;
|
||||||
using Microsoft.AspNetCore.Antiforgery;
|
using Microsoft.AspNetCore.Antiforgery;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||||
@@ -25,12 +24,11 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoints.MapGet(
|
// GET /login is served by the [AllowAnonymous] Blazor <Login> component
|
||||||
"/login",
|
// (Components/Pages/Login.razor → @page "/login"), which renders the shared
|
||||||
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery))
|
// kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
|
||||||
.AllowAnonymous()
|
// RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
|
||||||
.WithName("DashboardLogin");
|
// so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
|
||||||
|
|
||||||
endpoints.MapPost(
|
endpoints.MapPost(
|
||||||
"/login",
|
"/login",
|
||||||
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||||
@@ -92,17 +90,6 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
return endpoints;
|
return endpoints;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task<ContentHttpResult> GetLoginAsync(
|
|
||||||
HttpContext httpContext,
|
|
||||||
IAntiforgery antiforgery)
|
|
||||||
{
|
|
||||||
string returnUrl = SanitizeReturnUrl(httpContext.Request.Query["returnUrl"].ToString());
|
|
||||||
|
|
||||||
return Task.FromResult(TypedResults.Content(
|
|
||||||
RenderLoginPage(httpContext, antiforgery, returnUrl, failureMessage: null),
|
|
||||||
"text/html"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<IResult> PostLoginAsync(
|
private static async Task<IResult> PostLoginAsync(
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
IAntiforgery antiforgery,
|
IAntiforgery antiforgery,
|
||||||
@@ -124,10 +111,13 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
|
|
||||||
if (!result.Succeeded || result.Principal is null)
|
if (!result.Succeeded || result.Principal is null)
|
||||||
{
|
{
|
||||||
return TypedResults.Content(
|
// Round-trip the failure back to the anonymous Blazor /login page, carrying
|
||||||
RenderLoginPage(httpContext, antiforgery, returnUrl, result.FailureMessage),
|
// the (sanitized) returnUrl so a successful retry still lands on the target.
|
||||||
"text/html",
|
string failureMessage = result.FailureMessage
|
||||||
statusCode: StatusCodes.Status401Unauthorized);
|
?? "The username or password is invalid, or the user is not authorized.";
|
||||||
|
return Results.Redirect(
|
||||||
|
$"/login?error={Uri.EscapeDataString(failureMessage)}"
|
||||||
|
+ $"&returnUrl={Uri.EscapeDataString(returnUrl)}");
|
||||||
}
|
}
|
||||||
|
|
||||||
await httpContext
|
await httpContext
|
||||||
@@ -158,42 +148,6 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
return Results.LocalRedirect("/login");
|
return Results.LocalRedirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string RenderLoginPage(
|
|
||||||
HttpContext httpContext,
|
|
||||||
IAntiforgery antiforgery,
|
|
||||||
string returnUrl,
|
|
||||||
string? failureMessage)
|
|
||||||
{
|
|
||||||
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
|
|
||||||
string requestToken = tokens.RequestToken ?? string.Empty;
|
|
||||||
string alert = string.IsNullOrWhiteSpace(failureMessage)
|
|
||||||
? string.Empty
|
|
||||||
: $"<p class=\"alert alert-danger\" role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
|
||||||
|
|
||||||
string body = $"""
|
|
||||||
<section class="dashboard-login">
|
|
||||||
{alert}
|
|
||||||
<form method="post" action="/login" class="card login-card">
|
|
||||||
<div class="card-body">
|
|
||||||
<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="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>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
""";
|
|
||||||
|
|
||||||
return RenderPage("Dashboard Sign In", heading: null, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string RenderPage(string title, string body)
|
private static string RenderPage(string title, string body)
|
||||||
=> RenderPage(title, heading: title, body);
|
=> RenderPage(title, heading: title, body);
|
||||||
|
|
||||||
@@ -215,7 +169,8 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
||||||
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||||
<link rel="stylesheet" href="/css/theme.css" />
|
<link rel="stylesheet" href="/_content/ZB.MOM.WW.Theme/css/theme.css" />
|
||||||
|
<link rel="stylesheet" href="/_content/ZB.MOM.WW.Theme/css/layout.css" />
|
||||||
<link rel="stylesheet" href="/css/site.css" />
|
<link rel="stylesheet" href="/css/site.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="dashboard-body">
|
<body class="dashboard-body">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ internal static class DashboardGalaxyProjector
|
|||||||
{
|
{
|
||||||
/// <summary>Projects the cache entry to a dashboard Galaxy summary.</summary>
|
/// <summary>Projects the cache entry to a dashboard Galaxy summary.</summary>
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
|
/// <returns>The precomputed <see cref="DashboardGalaxySummary"/> from the cache entry.</returns>
|
||||||
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
|
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
|
||||||
{
|
{
|
||||||
return entry.DashboardSummary;
|
return entry.DashboardSummary;
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared-Auth <see cref="IGroupRoleMapper{TRole}"/> seam over the dashboard's
|
||||||
|
/// LDAP-group → role mapping. Roles are plain strings
|
||||||
|
/// (<see cref="DashboardRoles.Admin"/> / <see cref="DashboardRoles.Viewer"/>),
|
||||||
|
/// so <c>TRole</c> is <see cref="string"/>. The mapping rules (full-DN first,
|
||||||
|
/// leading-RDN fallback, case-insensitive) live in
|
||||||
|
/// <see cref="DashboardGroupRoleMapping"/>, shared with
|
||||||
|
/// <see cref="DashboardAuthenticator"/> so behaviour stays identical.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="options">Gateway options supplying the dashboard GroupToRole map.</param>
|
||||||
|
public sealed class DashboardGroupRoleMapper(IOptions<GatewayOptions> options)
|
||||||
|
: IGroupRoleMapper<string>
|
||||||
|
{
|
||||||
|
/// <summary>Maps LDAP group memberships to dashboard roles using the configured group-to-role rules.</summary>
|
||||||
|
/// <param name="groups">The list of LDAP group names or distinguished names for the authenticated user.</param>
|
||||||
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the role mapping for the supplied groups.</returns>
|
||||||
|
public Task<GroupRoleMapping<string>> MapAsync(
|
||||||
|
IReadOnlyList<string> groups,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> roles = DashboardGroupRoleMapping.MapGroupsToRoles(
|
||||||
|
groups,
|
||||||
|
options.Value.Dashboard.GroupToRole);
|
||||||
|
|
||||||
|
return Task.FromResult(new GroupRoleMapping<string>(roles, Scope: null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth for mapping a user's LDAP groups to dashboard roles.
|
||||||
|
/// Both <see cref="DashboardAuthenticator"/> (the existing login flow) and
|
||||||
|
/// <see cref="DashboardGroupRoleMapper"/> (the shared-Auth
|
||||||
|
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Roles.IGroupRoleMapper{TRole}"/> seam)
|
||||||
|
/// delegate here so the precedence and case rules stay identical.
|
||||||
|
/// </summary>
|
||||||
|
internal static class DashboardGroupRoleMapping
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the user's LDAP groups to dashboard roles. A user can pick up
|
||||||
|
/// multiple roles; Admin and Viewer are the only legal values. Returns
|
||||||
|
/// an empty list when no group matches (caller rejects the login).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
|
||||||
|
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
|
||||||
|
/// <returns>The distinct set of dashboard roles matched from the user's groups.</returns>
|
||||||
|
internal static IReadOnlyList<string> MapGroupsToRoles(
|
||||||
|
IEnumerable<string> groups,
|
||||||
|
IReadOnlyDictionary<string, string> groupToRole)
|
||||||
|
{
|
||||||
|
if (groupToRole.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<string> roles = new(StringComparer.Ordinal);
|
||||||
|
foreach (string group in groups)
|
||||||
|
{
|
||||||
|
string normalizedGroup = group.Trim();
|
||||||
|
|
||||||
|
// Lookup precedence (Server-040): the full literal group string is
|
||||||
|
// tried first; only if that misses do we fall back to the leading
|
||||||
|
// RDN value (e.g. "GwAdmin" extracted from
|
||||||
|
// "ou=GwAdmin,ou=groups,..."). The map's comparer is
|
||||||
|
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g.
|
||||||
|
// "GwAdmin" and "gwadmin" both match.
|
||||||
|
//
|
||||||
|
// Review C1: with the shared ZB.MOM.WW.Auth.Ldap provider, groups
|
||||||
|
// arrive here already stripped to short RDN names (the library calls
|
||||||
|
// FirstRdnValue before returning them). So through the live login path
|
||||||
|
// the full-string branch only ever sees short names and the RDN
|
||||||
|
// fallback is effectively a no-op — they collapse to the same key.
|
||||||
|
// The fallback is retained because this mapping is also reachable
|
||||||
|
// directly via the IGroupRoleMapper<string> seam (DashboardGroupRoleMapper),
|
||||||
|
// where a caller could still pass a full DN. CONSEQUENCE: configuring a
|
||||||
|
// full-DN GroupToRole *key* (e.g. "ou=GwAdmin,ou=groups,...") is
|
||||||
|
// UNSUPPORTED with the shared library — the incoming group is a short
|
||||||
|
// name, so it will never equal a full-DN key. Keep GroupToRole keys as
|
||||||
|
// short group names.
|
||||||
|
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|
||||||
|
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
|
||||||
|
{
|
||||||
|
roles.Add(mapped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.. roles];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
|
||||||
|
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
||||||
|
/// <returns>The value portion of the first RDN component, or the full string if no <c>=</c> is found.</returns>
|
||||||
|
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..];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -192,7 +192,8 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Releases resources and closes the associated gateway session.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous dispose operation.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ public static class DashboardRoles
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read-write access: API-key CRUD, settings, any state-changing UI.
|
/// Read-write access: API-key CRUD, settings, any state-changing UI.
|
||||||
|
/// Canonical role value (Task 1.7); formerly <c>"Admin"</c> — pure value
|
||||||
|
/// rename, the operations this role authorizes are unchanged.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string Admin = "Admin";
|
public const string Admin = "Administrator";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Read-only access: all pages render but write affordances are hidden.
|
/// Read-only access: all pages render but write affordances are hidden.
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
using ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
@@ -15,11 +19,26 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
/// Registers all dashboard services, authentication, and Razor components.
|
/// Registers all dashboard services, authentication, and Razor components.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="services">Service collection to register services.</param>
|
/// <param name="services">Service collection to register services.</param>
|
||||||
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
|
/// <param name="configuration">
|
||||||
|
/// Application configuration, used to bind the shared LDAP provider's options
|
||||||
|
/// from the <c>MxGateway:Ldap</c> section.
|
||||||
|
/// </param>
|
||||||
|
/// <returns>The <paramref name="services"/> collection for chaining.</returns>
|
||||||
|
public static IServiceCollection AddGatewayDashboard(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
|
// Dashboard logins delegate bind/search to the shared ZB.MOM.WW.Auth.Ldap
|
||||||
|
// provider. Its LdapOptions bind straight from MxGateway:Ldap (the gateway's
|
||||||
|
// LdapOptions field names match the shared options: Transport / AllowInsecure /
|
||||||
|
// SearchBase / ServiceAccount* / *Attribute). AddZbLdapAuth also adds a
|
||||||
|
// ValidateOnStart() so an insecure-transport misconfiguration fails fast at boot.
|
||||||
|
services.AddZbLdapAuth(configuration, "MxGateway:Ldap");
|
||||||
|
|
||||||
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||||
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
|
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
|
||||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||||
|
services.AddSingleton<IGroupRoleMapper<string>, DashboardGroupRoleMapper>();
|
||||||
services.AddSingleton<DashboardApiKeyAuthorization>();
|
services.AddSingleton<DashboardApiKeyAuthorization>();
|
||||||
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
||||||
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
|
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
|
||||||
@@ -30,6 +49,7 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
services.AddHostedService<Hubs.DashboardSnapshotPublisher>();
|
services.AddHostedService<Hubs.DashboardSnapshotPublisher>();
|
||||||
services.AddHostedService<Hubs.AlarmsHubPublisher>();
|
services.AddHostedService<Hubs.AlarmsHubPublisher>();
|
||||||
services.AddHttpContextAccessor();
|
services.AddHttpContextAccessor();
|
||||||
|
services.AddSingleton<IAuditActorAccessor, HttpAuditActorAccessor>();
|
||||||
services.AddAntiforgery();
|
services.AddAntiforgery();
|
||||||
services.AddCascadingAuthenticationState();
|
services.AddCascadingAuthenticationState();
|
||||||
services.AddRazorComponents()
|
services.AddRazorComponents()
|
||||||
@@ -40,23 +60,25 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions =>
|
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions =>
|
||||||
{
|
{
|
||||||
|
// Hardened defaults (HttpOnly, SameSite=Strict, SecurePolicy, SlidingExpiration,
|
||||||
|
// ExpireTimeSpan) via the shared ZbCookieDefaults.Apply. requireHttps is set to
|
||||||
|
// its default (true / Always) here and overridden per-environment by the
|
||||||
|
// PostConfigure below; the 8-hour idle timeout is preserved (not the 30-min default).
|
||||||
|
ZbCookieDefaults.Apply(cookieOptions, requireHttps: true, idleTimeout: TimeSpan.FromHours(8));
|
||||||
|
// Cookie name, path, and redirect paths are MxGateway-specific — set after Apply
|
||||||
|
// so they are never overwritten by the shared helper (Apply intentionally skips name).
|
||||||
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
||||||
cookieOptions.Cookie.HttpOnly = true;
|
|
||||||
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
|
|
||||||
// SecurePolicy is bound via PostConfigure below so it can honour
|
|
||||||
// DashboardOptions.RequireHttpsCookie (default Always; dev HTTP
|
|
||||||
// deployments set RequireHttpsCookie=false to use SameAsRequest).
|
|
||||||
cookieOptions.Cookie.Path = "/";
|
cookieOptions.Cookie.Path = "/";
|
||||||
cookieOptions.LoginPath = "/login";
|
cookieOptions.LoginPath = "/login";
|
||||||
cookieOptions.LogoutPath = "/logout";
|
cookieOptions.LogoutPath = "/logout";
|
||||||
cookieOptions.AccessDeniedPath = "/denied";
|
cookieOptions.AccessDeniedPath = "/denied";
|
||||||
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
|
|
||||||
cookieOptions.SlidingExpiration = true;
|
|
||||||
})
|
})
|
||||||
.AddScheme<AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
|
.AddScheme<AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
|
||||||
DashboardAuthenticationDefaults.HubAuthenticationScheme,
|
DashboardAuthenticationDefaults.HubAuthenticationScheme,
|
||||||
_ => { });
|
_ => { });
|
||||||
|
|
||||||
|
// Honour DashboardOptions.RequireHttpsCookie (default true / Always; set false for dev
|
||||||
|
// HTTP deployments → SameAsRequest). This overrides the Apply default above.
|
||||||
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
|
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public sealed record DashboardSessionAdminResult(
|
|||||||
{
|
{
|
||||||
/// <summary>Creates a successful result with the given message.</summary>
|
/// <summary>Creates a successful result with the given message.</summary>
|
||||||
/// <param name="message">The result message.</param>
|
/// <param name="message">The result message.</param>
|
||||||
|
/// <returns>A <see cref="DashboardSessionAdminResult"/> with <c>Succeeded</c> set to <c>true</c>.</returns>
|
||||||
public static DashboardSessionAdminResult Success(string message)
|
public static DashboardSessionAdminResult Success(string message)
|
||||||
{
|
{
|
||||||
return new DashboardSessionAdminResult(true, message);
|
return new DashboardSessionAdminResult(true, message);
|
||||||
@@ -13,6 +14,7 @@ public sealed record DashboardSessionAdminResult(
|
|||||||
|
|
||||||
/// <summary>Creates a failed result with the given message.</summary>
|
/// <summary>Creates a failed result with the given message.</summary>
|
||||||
/// <param name="message">The result message.</param>
|
/// <param name="message">The result message.</param>
|
||||||
|
/// <returns>A <see cref="DashboardSessionAdminResult"/> with <c>Succeeded</c> set to <c>false</c>.</returns>
|
||||||
public static DashboardSessionAdminResult Fail(string message)
|
public static DashboardSessionAdminResult Fail(string message)
|
||||||
{
|
{
|
||||||
return new DashboardSessionAdminResult(false, message);
|
return new DashboardSessionAdminResult(false, message);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Runtime.CompilerServices;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||||
@@ -64,10 +65,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
|
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets a current dashboard snapshot of gateway state.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Dashboard snapshot.</returns>
|
|
||||||
public DashboardSnapshot GetSnapshot()
|
public DashboardSnapshot GetSnapshot()
|
||||||
{
|
{
|
||||||
DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
|
DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
|
||||||
@@ -99,11 +97,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
|
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Watches dashboard snapshots at regular intervals asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>Async enumerable of dashboard snapshots.</returns>
|
|
||||||
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
|
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -242,7 +236,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
KeyId: key.KeyId,
|
KeyId: key.KeyId,
|
||||||
DisplayName: key.DisplayName,
|
DisplayName: key.DisplayName,
|
||||||
Scopes: key.Scopes,
|
Scopes: key.Scopes,
|
||||||
Constraints: key.Constraints,
|
Constraints: ApiKeyConstraintSerializer.Deserialize(key.ConstraintsJson),
|
||||||
CreatedUtc: key.CreatedUtc,
|
CreatedUtc: key.CreatedUtc,
|
||||||
LastUsedUtc: key.LastUsedUtc,
|
LastUsedUtc: key.LastUsedUtc,
|
||||||
RevokedUtc: key.RevokedUtc))
|
RevokedUtc: key.RevokedUtc))
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public sealed class HubTokenService
|
|||||||
|
|
||||||
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
|
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
|
||||||
/// <param name="user">The claims principal representing the user.</param>
|
/// <param name="user">The claims principal representing the user.</param>
|
||||||
|
/// <returns>The time-limited bearer token string.</returns>
|
||||||
public string Issue(ClaimsPrincipal user)
|
public string Issue(ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(user);
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
@@ -52,6 +53,7 @@ public sealed class HubTokenService
|
|||||||
|
|
||||||
/// <summary>Validates a token and returns the equivalent claims principal; null when invalid or expired.</summary>
|
/// <summary>Validates a token and returns the equivalent claims principal; null when invalid or expired.</summary>
|
||||||
/// <param name="token">The token string to validate.</param>
|
/// <param name="token">The token string to validate.</param>
|
||||||
|
/// <returns>The claims principal if the token is valid, or null if invalid or expired.</returns>
|
||||||
public ClaimsPrincipal? Validate(string? token)
|
public ClaimsPrincipal? Validate(string? token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(token))
|
if (string.IsNullOrEmpty(token))
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ public sealed class DashboardEventBroadcaster(
|
|||||||
IHubContext<EventsHub> hubContext,
|
IHubContext<EventsHub> hubContext,
|
||||||
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
|
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
|
||||||
{
|
{
|
||||||
/// <summary>Publishes an MX event to connected dashboard clients.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="sessionId">The session identifier.</param>
|
|
||||||
/// <param name="mxEvent">The MX event to publish.</param>
|
|
||||||
public void Publish(string sessionId, MxEvent mxEvent)
|
public void Publish(string sessionId, MxEvent mxEvent)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(sessionId) || mxEvent is null)
|
if (string.IsNullOrEmpty(sessionId) || mxEvent is null)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public sealed class EventsHub : Hub
|
|||||||
/// dedicated authorization policy applied to the hub method itself.
|
/// dedicated authorization policy applied to the hub method itself.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="sessionId">Session id to subscribe the caller to.</param>
|
/// <param name="sessionId">Session id to subscribe the caller to.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public Task SubscribeSession(string sessionId)
|
public Task SubscribeSession(string sessionId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(sessionId))
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public interface IDashboardAuthenticator
|
|||||||
/// <param name="username">Username to authenticate.</param>
|
/// <param name="username">Username to authenticate.</param>
|
||||||
/// <param name="password">Password to authenticate.</param>
|
/// <param name="password">Password to authenticate.</param>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the authentication result.</returns>
|
||||||
Task<DashboardAuthenticationResult> AuthenticateAsync(
|
Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||||
string? username,
|
string? username,
|
||||||
string? password,
|
string? password,
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ public interface IDashboardBrowseService
|
|||||||
{
|
{
|
||||||
/// <summary>Returns root browse nodes (objects with no parent).</summary>
|
/// <summary>Returns root browse nodes (objects with no parent).</summary>
|
||||||
/// <param name="filter">Filter arguments forwarded to the projector.</param>
|
/// <param name="filter">Filter arguments forwarded to the projector.</param>
|
||||||
|
/// <returns>The root-level browse result.</returns>
|
||||||
BrowseLevelResult GetRoots(BrowseFilterArgs filter);
|
BrowseLevelResult GetRoots(BrowseFilterArgs filter);
|
||||||
|
|
||||||
/// <summary>Returns the direct children of the given parent gobject id.</summary>
|
/// <summary>Returns the direct children of the given parent gobject id.</summary>
|
||||||
/// <param name="parentGobjectId">The Galaxy gobject id of the parent to expand.</param>
|
/// <param name="parentGobjectId">The Galaxy gobject id of the parent to expand.</param>
|
||||||
/// <param name="filter">Filter arguments forwarded to the projector.</param>
|
/// <param name="filter">Filter arguments forwarded to the projector.</param>
|
||||||
|
/// <returns>The children browse result for the specified parent.</returns>
|
||||||
BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
|
BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
|
||||||
|
|
||||||
/// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary>
|
/// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary>
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ public interface IDashboardSnapshotService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the current dashboard snapshot.
|
/// Gets the current dashboard snapshot.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>The most recent <see cref="DashboardSnapshot"/>.</returns>
|
||||||
DashboardSnapshot GetSnapshot();
|
DashboardSnapshot GetSnapshot();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Watches for changes to the dashboard state as an async enumerable.
|
/// Watches for changes to the dashboard state as an async enumerable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>An async sequence of <see cref="DashboardSnapshot"/> values as state changes.</returns>
|
||||||
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
|
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Readiness probe: verifies the SQLite authentication store is reachable. The gateway
|
||||||
|
/// authenticates every gRPC call against this store, so its reachability gates readiness.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class AuthStoreHealthCheck : IHealthCheck
|
||||||
|
{
|
||||||
|
private readonly AuthSqliteConnectionFactory _connectionFactory;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of <see cref="AuthStoreHealthCheck"/> with the given connection factory.</summary>
|
||||||
|
/// <param name="connectionFactory">Factory for opening SQLite connections to the auth store.</param>
|
||||||
|
public AuthStoreHealthCheck(AuthSqliteConnectionFactory connectionFactory) =>
|
||||||
|
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||||
|
|
||||||
|
/// <summary>Runs a lightweight connectivity probe against the SQLite authentication store.</summary>
|
||||||
|
/// <param name="context">Health check context supplied by the framework.</param>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the health check result.</returns>
|
||||||
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
|
HealthCheckContext context,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using SqliteConnection connection =
|
||||||
|
await _connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using SqliteCommand command = connection.CreateCommand();
|
||||||
|
command.CommandText = "SELECT 1;";
|
||||||
|
await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return HealthCheckResult.Healthy("Auth store is reachable.");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return HealthCheckResult.Unhealthy("Auth store is unreachable.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public static class GatewayLogRedactor
|
|||||||
/// Determines whether a command method bears credentials.
|
/// Determines whether a command method bears credentials.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="commandMethod">The command method name to check.</param>
|
/// <param name="commandMethod">The command method name to check.</param>
|
||||||
|
/// <returns><c>true</c> if the method carries credentials; otherwise <c>false</c>.</returns>
|
||||||
public static bool IsCredentialBearingCommand(string? commandMethod)
|
public static bool IsCredentialBearingCommand(string? commandMethod)
|
||||||
{
|
{
|
||||||
return commandMethod is not null
|
return commandMethod is not null
|
||||||
@@ -29,6 +30,7 @@ public static class GatewayLogRedactor
|
|||||||
/// Redacts the API key secret portion of a Bearer authorization header.
|
/// Redacts the API key secret portion of a Bearer authorization header.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="authorizationHeader">The authorization header value to redact.</param>
|
/// <param name="authorizationHeader">The authorization header value to redact.</param>
|
||||||
|
/// <returns>The header with the secret portion replaced by <see cref="RedactedValue"/>, or the original if no key is detected.</returns>
|
||||||
public static string? RedactApiKey(string? authorizationHeader)
|
public static string? RedactApiKey(string? authorizationHeader)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
||||||
@@ -62,6 +64,7 @@ public static class GatewayLogRedactor
|
|||||||
/// Redacts the client identity if it contains an API key.
|
/// Redacts the client identity if it contains an API key.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="clientIdentity">The client identity string to redact.</param>
|
/// <param name="clientIdentity">The client identity string to redact.</param>
|
||||||
|
/// <returns>The redacted identity string, or the original if no key pattern is found.</returns>
|
||||||
public static string? RedactClientIdentity(string? clientIdentity)
|
public static string? RedactClientIdentity(string? clientIdentity)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(clientIdentity))
|
if (string.IsNullOrWhiteSpace(clientIdentity))
|
||||||
@@ -80,6 +83,7 @@ public static class GatewayLogRedactor
|
|||||||
/// <param name="commandMethod">The command method name to check for credentials.</param>
|
/// <param name="commandMethod">The command method name to check for credentials.</param>
|
||||||
/// <param name="value">The command value to redact.</param>
|
/// <param name="value">The command value to redact.</param>
|
||||||
/// <param name="valueLoggingEnabled">Whether value logging is enabled.</param>
|
/// <param name="valueLoggingEnabled">Whether value logging is enabled.</param>
|
||||||
|
/// <returns>The original value when logging is enabled and the command is not credential-bearing; otherwise <see cref="RedactedValue"/>.</returns>
|
||||||
public static object? RedactCommandValue(
|
public static object? RedactCommandValue(
|
||||||
string? commandMethod,
|
string? commandMethod,
|
||||||
object? value,
|
object? value,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using ZB.MOM.WW.Telemetry.Serilog;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapts the static <see cref="GatewayLogRedactor"/> to the shared <see cref="ILogRedactor"/> seam
|
||||||
|
/// so the telemetry RedactionEnricher masks API-key/credential material on every log event.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class GatewayLogRedactorSeam : ILogRedactor
|
||||||
|
{
|
||||||
|
private static readonly string[] IdentityKeys = ["ClientIdentity", "authorization", "Authorization"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Masks API-key/credential material in known identity-bearing log properties.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="properties">The log event property dictionary to redact in place.</param>
|
||||||
|
public void Redact(IDictionary<string, object?> properties)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(properties);
|
||||||
|
foreach (var key in IdentityKeys)
|
||||||
|
{
|
||||||
|
if (properties.TryGetValue(key, out var value) && value is string s)
|
||||||
|
{
|
||||||
|
properties[key] = GatewayLogRedactor.RedactClientIdentity(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ public sealed record GatewayLogScope(
|
|||||||
string? ClientIdentity = null)
|
string? ClientIdentity = null)
|
||||||
{
|
{
|
||||||
/// <summary>Converts the log scope to a dictionary with redacted sensitive fields.</summary>
|
/// <summary>Converts the log scope to a dictionary with redacted sensitive fields.</summary>
|
||||||
|
/// <returns>A dictionary of non-null scope properties with sensitive fields redacted.</returns>
|
||||||
public IReadOnlyDictionary<string, object?> ToDictionary()
|
public IReadOnlyDictionary<string, object?> ToDictionary()
|
||||||
{
|
{
|
||||||
Dictionary<string, object?> values = [];
|
Dictionary<string, object?> values = [];
|
||||||
|
|||||||
+1
@@ -19,6 +19,7 @@ public static class GatewayRequestLoggingMiddlewareExtensions
|
|||||||
|
|
||||||
/// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary>
|
/// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary>
|
||||||
/// <param name="app">Application builder.</param>
|
/// <param name="app">Application builder.</param>
|
||||||
|
/// <returns>The <paramref name="app"/> instance for chaining.</returns>
|
||||||
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
|
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(app);
|
ArgumentNullException.ThrowIfNull(app);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ public static class GalaxyBrowseProjector
|
|||||||
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
||||||
/// <param name="offset">Zero-based offset into the filtered child list.</param>
|
/// <param name="offset">Zero-based offset into the filtered child list.</param>
|
||||||
/// <param name="pageSize">Maximum number of children to return.</param>
|
/// <param name="pageSize">Maximum number of children to return.</param>
|
||||||
|
/// <returns>A page of children with total count and filter signature.</returns>
|
||||||
public static GalaxyBrowseChildrenResult ProjectChildren(
|
public static GalaxyBrowseChildrenResult ProjectChildren(
|
||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
BrowseChildrenRequest request,
|
BrowseChildrenRequest request,
|
||||||
@@ -71,6 +72,7 @@ public static class GalaxyBrowseProjector
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
|
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
|
||||||
/// <param name="request">The browse-children request.</param>
|
/// <param name="request">The browse-children request.</param>
|
||||||
|
/// <returns>The resolved parent gobject id, or 0 for roots.</returns>
|
||||||
public static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
|
public static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
|
||||||
{
|
{
|
||||||
switch (request.ParentCase)
|
switch (request.ParentCase)
|
||||||
@@ -257,6 +259,7 @@ public static class GalaxyBrowseProjector
|
|||||||
/// <param name="request">The browse-children request.</param>
|
/// <param name="request">The browse-children request.</param>
|
||||||
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
||||||
/// <param name="parentId">Resolved parent gobject id (0 for roots).</param>
|
/// <param name="parentId">Resolved parent gobject id (0 for roots).</param>
|
||||||
|
/// <returns>A hex-encoded SHA-256 prefix that uniquely identifies the filter combination.</returns>
|
||||||
public static string ComputeFilterSignature(
|
public static string ComputeFilterSignature(
|
||||||
BrowseChildrenRequest request,
|
BrowseChildrenRequest request,
|
||||||
IReadOnlyList<string>? browseSubtreeGlobs,
|
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
|
|||||||
private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new();
|
private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new();
|
||||||
private GalaxyDeployEventInfo? _latest;
|
private GalaxyDeployEventInfo? _latest;
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// The most recent deploy event, or null if none has been published.
|
|
||||||
/// </summary>
|
|
||||||
public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest);
|
public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public static class GalaxyGlobMatcher
|
|||||||
/// <summary>Determines whether a value matches a glob pattern (with * and ? wildcards).</summary>
|
/// <summary>Determines whether a value matches a glob pattern (with * and ? wildcards).</summary>
|
||||||
/// <param name="value">The value to test against the glob pattern.</param>
|
/// <param name="value">The value to test against the glob pattern.</param>
|
||||||
/// <param name="glob">The glob pattern with * and ? wildcards.</param>
|
/// <param name="glob">The glob pattern with * and ? wildcards.</param>
|
||||||
|
/// <returns><see langword="true"/> if the value matches the glob pattern; otherwise <see langword="false"/>.</returns>
|
||||||
public static bool IsMatch(string value, string glob)
|
public static bool IsMatch(string value, string glob)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(glob))
|
if (string.IsNullOrWhiteSpace(glob))
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|||||||
_snapshotStore = snapshotStore;
|
_snapshotStore = snapshotStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gets the current Galaxy hierarchy cache entry with projected status.</summary>
|
/// <inheritdoc />
|
||||||
public GalaxyHierarchyCacheEntry Current
|
public GalaxyHierarchyCacheEntry Current
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -74,9 +74,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Refreshes the Galaxy hierarchy cache if the deploy time has advanced.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
/// <returns>Asynchronous task representing the refresh operation.</returns>
|
|
||||||
public async Task RefreshAsync(CancellationToken cancellationToken)
|
public async Task RefreshAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -90,9 +88,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Waits for the Galaxy hierarchy cache to complete its first load.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
/// <returns>Asynchronous task representing the wait operation.</returns>
|
|
||||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken)
|
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return _firstLoad.Task.WaitAsync(cancellationToken);
|
return _firstLoad.Task.WaitAsync(cancellationToken);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
/// <param name="request">The discovery hierarchy request.</param>
|
/// <param name="request">The discovery hierarchy request.</param>
|
||||||
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
||||||
|
/// <returns>The query result containing matching objects.</returns>
|
||||||
public static GalaxyHierarchyQueryResult Project(
|
public static GalaxyHierarchyQueryResult Project(
|
||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
@@ -44,6 +45,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
||||||
/// <param name="offset">The zero-based offset into the result set.</param>
|
/// <param name="offset">The zero-based offset into the result set.</param>
|
||||||
/// <param name="pageSize">The maximum number of results to return.</param>
|
/// <param name="pageSize">The maximum number of results to return.</param>
|
||||||
|
/// <returns>The query result containing the requested page of matching objects.</returns>
|
||||||
public static GalaxyHierarchyQueryResult Project(
|
public static GalaxyHierarchyQueryResult Project(
|
||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
@@ -131,6 +133,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
/// <summary>Finds an object in the hierarchy by its tag address.</summary>
|
/// <summary>Finds an object in the hierarchy by its tag address.</summary>
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
/// <param name="tagAddress">The tag address to search for.</param>
|
/// <param name="tagAddress">The tag address to search for.</param>
|
||||||
|
/// <returns>The matching Galaxy object, or <c>null</c> if not found.</returns>
|
||||||
public static GalaxyObject? FindObjectForTag(
|
public static GalaxyObject? FindObjectForTag(
|
||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
string tagAddress)
|
string tagAddress)
|
||||||
@@ -148,6 +151,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
/// <summary>Finds an attribute in the hierarchy by its tag address.</summary>
|
/// <summary>Finds an attribute in the hierarchy by its tag address.</summary>
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
/// <param name="tagAddress">The tag address to search for.</param>
|
/// <param name="tagAddress">The tag address to search for.</param>
|
||||||
|
/// <returns>The matching Galaxy attribute, or <c>null</c> if not found.</returns>
|
||||||
public static GalaxyAttribute? FindAttributeForTag(
|
public static GalaxyAttribute? FindAttributeForTag(
|
||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
string tagAddress)
|
string tagAddress)
|
||||||
@@ -165,6 +169,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
/// <summary>Gets the contained path for an object by its gobject ID.</summary>
|
/// <summary>Gets the contained path for an object by its gobject ID.</summary>
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
/// <param name="gobjectId">The Galaxy object ID.</param>
|
/// <param name="gobjectId">The Galaxy object ID.</param>
|
||||||
|
/// <returns>The contained path string, or an empty string if the object is not found.</returns>
|
||||||
public static string GetContainedPath(
|
public static string GetContainedPath(
|
||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
int gobjectId)
|
int gobjectId)
|
||||||
@@ -282,6 +287,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
/// <summary>Computes a stable filter signature for memoization purposes.</summary>
|
/// <summary>Computes a stable filter signature for memoization purposes.</summary>
|
||||||
/// <param name="request">The discovery hierarchy request.</param>
|
/// <param name="request">The discovery hierarchy request.</param>
|
||||||
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
||||||
|
/// <returns>A string key that uniquely identifies the combination of filter parameters.</returns>
|
||||||
public static string ComputeFilterSignature(
|
public static string ComputeFilterSignature(
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
IReadOnlyList<string>? browseSubtreeGlobs)
|
IReadOnlyList<string>? browseSubtreeGlobs)
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
|
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
|
||||||
{
|
{
|
||||||
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -31,8 +30,7 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR
|
|||||||
catch (InvalidOperationException) { return false; }
|
catch (InvalidOperationException) { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using SqlConnection conn = new(options.ConnectionString);
|
using SqlConnection conn = new(options.ConnectionString);
|
||||||
@@ -43,8 +41,7 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR
|
|||||||
return result is DateTime dt ? dt : null;
|
return result is DateTime dt ? dt : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
List<GalaxyHierarchyRow> rows = new();
|
List<GalaxyHierarchyRow> rows = new();
|
||||||
@@ -81,8 +78,7 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
List<GalaxyAttributeRow> rows = new();
|
List<GalaxyAttributeRow> rows = new();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public interface IGalaxyHierarchyCache
|
|||||||
/// refresh.
|
/// refresh.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
Task RefreshAsync(CancellationToken cancellationToken);
|
Task RefreshAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -21,5 +22,6 @@ public interface IGalaxyHierarchyCache
|
|||||||
/// very first request after gateway start.
|
/// very first request after gateway start.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
Task WaitForFirstLoadAsync(CancellationToken cancellationToken);
|
Task WaitForFirstLoadAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public interface IGalaxyHierarchySnapshotStore
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="snapshot">The browse dataset to persist.</param>
|
/// <param name="snapshot">The browse dataset to persist.</param>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous save operation.</returns>
|
||||||
Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken);
|
Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -14,17 +14,21 @@ public interface IGalaxyRepository
|
|||||||
{
|
{
|
||||||
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to <see langword="true"/> if the connection succeeds; otherwise <see langword="false"/>.</returns>
|
||||||
Task<bool> TestConnectionAsync(CancellationToken ct = default);
|
Task<bool> TestConnectionAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the last deploy time, or <see langword="null"/> if not available.</returns>
|
||||||
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
|
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the list of hierarchy rows.</returns>
|
||||||
Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default);
|
Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the list of attribute rows.</returns>
|
||||||
Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default);
|
Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Security.Cryptography.X509Certificates;
|
|||||||
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Configuration;
|
using Microsoft.Extensions.Logging.Configuration;
|
||||||
|
using ZB.MOM.WW.Health;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts;
|
using ZB.MOM.WW.MxGateway.Contracts;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
@@ -14,6 +15,8 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|||||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||||
|
using ZB.MOM.WW.Telemetry;
|
||||||
|
using ZB.MOM.WW.Telemetry.Serilog;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server;
|
namespace ZB.MOM.WW.MxGateway.Server;
|
||||||
|
|
||||||
@@ -60,18 +63,35 @@ public static class GatewayApplication
|
|||||||
|
|
||||||
ConfigureSelfSignedTls(builder);
|
ConfigureSelfSignedTls(builder);
|
||||||
|
|
||||||
builder.Services.AddGatewayConfiguration();
|
builder.AddZbSerilog(o => o.ServiceName = "mxgateway");
|
||||||
builder.Services.AddSqliteAuthStore();
|
|
||||||
|
builder.Services.AddGatewayConfiguration(builder.Configuration);
|
||||||
|
builder.Services.AddSqliteAuthStore(builder.Configuration);
|
||||||
builder.Services.AddGatewayGrpcAuthorization();
|
builder.Services.AddGatewayGrpcAuthorization();
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks()
|
||||||
|
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
|
||||||
|
"auth-store",
|
||||||
|
failureStatus: null,
|
||||||
|
tags: new[] { ZbHealthTags.Ready });
|
||||||
builder.Services.AddSingleton<GatewayMetrics>();
|
builder.Services.AddSingleton<GatewayMetrics>();
|
||||||
|
builder.AddZbTelemetry(o =>
|
||||||
|
{
|
||||||
|
o.ServiceName = "mxgateway";
|
||||||
|
o.Meters = [GatewayMetrics.MeterName]; // "MxGateway.Server" — name unchanged
|
||||||
|
if (Enum.TryParse<ZbExporter>(builder.Configuration["MxGateway:Telemetry:Exporter"], ignoreCase: true, out var exporter))
|
||||||
|
o.Exporter = exporter;
|
||||||
|
var otlp = builder.Configuration["MxGateway:Telemetry:OtlpEndpoint"];
|
||||||
|
if (!string.IsNullOrWhiteSpace(otlp))
|
||||||
|
o.OtlpEndpoint = otlp;
|
||||||
|
});
|
||||||
|
builder.Services.AddSingleton<ILogRedactor, GatewayLogRedactorSeam>();
|
||||||
builder.Services.AddSingleton<MxAccessGrpcMapper>();
|
builder.Services.AddSingleton<MxAccessGrpcMapper>();
|
||||||
builder.Services.AddSingleton<MxAccessGrpcRequestValidator>();
|
builder.Services.AddSingleton<MxAccessGrpcRequestValidator>();
|
||||||
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
|
builder.Services.AddSingleton<IEventStreamService, EventStreamService>();
|
||||||
builder.Services.AddWorkerProcessLauncher();
|
builder.Services.AddWorkerProcessLauncher();
|
||||||
builder.Services.AddGatewaySessions();
|
builder.Services.AddGatewaySessions();
|
||||||
builder.Services.AddGatewayAlarms();
|
builder.Services.AddGatewayAlarms();
|
||||||
builder.Services.AddGatewayDashboard();
|
builder.Services.AddGatewayDashboard(builder.Configuration);
|
||||||
builder.Services.AddGalaxyRepository();
|
builder.Services.AddGalaxyRepository();
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
@@ -169,13 +189,8 @@ public static class GatewayApplication
|
|||||||
{
|
{
|
||||||
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
|
endpoints.MapStaticAssets(ResolveStaticAssetsManifestPath());
|
||||||
|
|
||||||
endpoints.MapGet(
|
endpoints.MapZbHealth();
|
||||||
"/health/live",
|
endpoints.MapZbMetrics();
|
||||||
() => Results.Ok(new GatewayHealthReply(
|
|
||||||
Status: "Healthy",
|
|
||||||
DefaultBackend: GatewayContractInfo.DefaultBackendName,
|
|
||||||
WorkerProtocolVersion: GatewayContractInfo.WorkerProtocolVersion)))
|
|
||||||
.WithName("LiveHealth");
|
|
||||||
|
|
||||||
endpoints.MapGrpcService<MxAccessGatewayService>();
|
endpoints.MapGrpcService<MxAccessGatewayService>();
|
||||||
endpoints.MapGrpcService<GalaxyRepositoryGrpcService>();
|
endpoints.MapGrpcService<GalaxyRepositoryGrpcService>();
|
||||||
|
|||||||
@@ -18,12 +18,7 @@ public sealed class EventStreamService(
|
|||||||
IDashboardEventBroadcaster dashboardEventBroadcaster,
|
IDashboardEventBroadcaster dashboardEventBroadcaster,
|
||||||
ILogger<EventStreamService> logger) : IEventStreamService
|
ILogger<EventStreamService> logger) : IEventStreamService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Streams events from a session to the client asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Stream events request.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>Async enumerable of MX events.</returns>
|
|
||||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
StreamEventsRequest request,
|
StreamEventsRequest request,
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public static class GalaxyProtoMapper
|
|||||||
/// <summary>Maps Galaxy hierarchy and attribute rows to Galaxy object protos.</summary>
|
/// <summary>Maps Galaxy hierarchy and attribute rows to Galaxy object protos.</summary>
|
||||||
/// <param name="hierarchy">Hierarchy rows from Galaxy Repository.</param>
|
/// <param name="hierarchy">Hierarchy rows from Galaxy Repository.</param>
|
||||||
/// <param name="attributes">Attribute rows from Galaxy Repository.</param>
|
/// <param name="attributes">Attribute rows from Galaxy Repository.</param>
|
||||||
|
/// <returns>An enumerable of mapped Galaxy object protos.</returns>
|
||||||
public static IEnumerable<GalaxyObject> MapHierarchy(
|
public static IEnumerable<GalaxyObject> MapHierarchy(
|
||||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||||
@@ -30,6 +31,7 @@ public static class GalaxyProtoMapper
|
|||||||
/// <summary>Maps a Galaxy hierarchy row to a Galaxy object proto.</summary>
|
/// <summary>Maps a Galaxy hierarchy row to a Galaxy object proto.</summary>
|
||||||
/// <param name="row">Hierarchy row from Galaxy Repository.</param>
|
/// <param name="row">Hierarchy row from Galaxy Repository.</param>
|
||||||
/// <param name="attributesByGobjectId">Attributes indexed by gobject ID.</param>
|
/// <param name="attributesByGobjectId">Attributes indexed by gobject ID.</param>
|
||||||
|
/// <returns>The mapped Galaxy object proto.</returns>
|
||||||
public static GalaxyObject MapObject(
|
public static GalaxyObject MapObject(
|
||||||
GalaxyHierarchyRow row,
|
GalaxyHierarchyRow row,
|
||||||
IReadOnlyDictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId)
|
IReadOnlyDictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId)
|
||||||
@@ -60,6 +62,7 @@ public static class GalaxyProtoMapper
|
|||||||
|
|
||||||
/// <summary>Maps a Galaxy attribute row to a Galaxy attribute proto.</summary>
|
/// <summary>Maps a Galaxy attribute row to a Galaxy attribute proto.</summary>
|
||||||
/// <param name="row">Attribute row from Galaxy Repository.</param>
|
/// <param name="row">Attribute row from Galaxy Repository.</param>
|
||||||
|
/// <returns>The mapped Galaxy attribute proto.</returns>
|
||||||
public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new()
|
public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new()
|
||||||
{
|
{
|
||||||
AttributeName = row.AttributeName,
|
AttributeName = row.AttributeName,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public interface IEventStreamService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">Request payload.</param>
|
/// <param name="request">Request payload.</param>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>An async enumerable of MXAccess events.</returns>
|
||||||
IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
StreamEventsRequest request,
|
StreamEventsRequest request,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -162,15 +162,6 @@ public sealed class MxAccessGatewayService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
|
||||||
/// Surfaces the public AcknowledgeAlarm RPC. Acknowledgement is
|
|
||||||
/// session-less: the gateway routes it through the always-on
|
|
||||||
/// <see cref="IGatewayAlarmService"/> monitor session. An
|
|
||||||
/// <c>alarm_full_reference</c> that parses as a canonical GUID forwards
|
|
||||||
/// to <c>AcknowledgeAlarmCommand</c>; a <c>Provider!Group.Tag</c>
|
|
||||||
/// reference forwards to <c>AcknowledgeAlarmByNameCommand</c>; anything
|
|
||||||
/// else returns an <c>InvalidRequest</c> diagnostic in the reply.
|
|
||||||
/// </remarks>
|
|
||||||
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
|
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
|
||||||
AcknowledgeAlarmRequest request,
|
AcknowledgeAlarmRequest request,
|
||||||
ServerCallContext context)
|
ServerCallContext context)
|
||||||
@@ -193,14 +184,6 @@ public sealed class MxAccessGatewayService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
|
||||||
/// Surfaces the public StreamAlarms RPC — the session-less central
|
|
||||||
/// alarm feed. The stream opens with one <c>active_alarm</c> per
|
|
||||||
/// currently-active alarm, then a single <c>snapshot_complete</c>, then
|
|
||||||
/// a <c>transition</c> for every subsequent change. Served by the
|
|
||||||
/// gateway's always-on <see cref="IGatewayAlarmService"/> monitor; any
|
|
||||||
/// number of clients fan out from the single monitor.
|
|
||||||
/// </remarks>
|
|
||||||
public override async Task StreamAlarms(
|
public override async Task StreamAlarms(
|
||||||
StreamAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
IServerStreamWriter<AlarmFeedMessage> responseStream,
|
IServerStreamWriter<AlarmFeedMessage> responseStream,
|
||||||
@@ -224,12 +207,6 @@ public sealed class MxAccessGatewayService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
|
||||||
/// Snapshot of the active-alarm cache maintained by the gateway's
|
|
||||||
/// always-on alarm monitor. Streams one <see cref="ActiveAlarmSnapshot"/>
|
|
||||||
/// per currently-active alarm and completes — no transitions are
|
|
||||||
/// emitted. Use <c>StreamAlarms</c> for a live transition feed.
|
|
||||||
/// </remarks>
|
|
||||||
public override async Task QueryActiveAlarms(
|
public override async Task QueryActiveAlarms(
|
||||||
QueryActiveAlarmsRequest request,
|
QueryActiveAlarmsRequest request,
|
||||||
IServerStreamWriter<ActiveAlarmSnapshot> responseStream,
|
IServerStreamWriter<ActiveAlarmSnapshot> responseStream,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Maps a gRPC MX command request to a worker command.
|
/// Maps a gRPC MX command request to a worker command.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">Request payload.</param>
|
/// <param name="request">Request payload.</param>
|
||||||
|
/// <returns>The mapped worker command.</returns>
|
||||||
public WorkerCommand MapCommand(MxCommandRequest request)
|
public WorkerCommand MapCommand(MxCommandRequest request)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
@@ -39,6 +40,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Maps a worker command reply to a gRPC MX command reply.
|
/// Maps a worker command reply to a gRPC MX command reply.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="reply">Worker command reply.</param>
|
/// <param name="reply">Worker command reply.</param>
|
||||||
|
/// <returns>The mapped gRPC command reply.</returns>
|
||||||
public MxCommandReply MapCommandReply(WorkerCommandReply reply)
|
public MxCommandReply MapCommandReply(WorkerCommandReply reply)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(reply);
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
@@ -58,6 +60,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Maps a worker event to a gRPC MX event.
|
/// Maps a worker event to a gRPC MX event.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="workerEvent">Worker event to map.</param>
|
/// <param name="workerEvent">Worker event to map.</param>
|
||||||
|
/// <returns>The mapped gRPC MX event.</returns>
|
||||||
public MxEvent MapEvent(WorkerEvent workerEvent)
|
public MxEvent MapEvent(WorkerEvent workerEvent)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(workerEvent);
|
ArgumentNullException.ThrowIfNull(workerEvent);
|
||||||
@@ -73,6 +76,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates an OK protocol status.
|
/// Creates an OK protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Ok"/>.</returns>
|
||||||
public static ProtocolStatus Ok(string message = "OK")
|
public static ProtocolStatus Ok(string message = "OK")
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -86,6 +90,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates an InvalidRequest protocol status.
|
/// Creates an InvalidRequest protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.InvalidRequest"/>.</returns>
|
||||||
public static ProtocolStatus InvalidRequest(string message)
|
public static ProtocolStatus InvalidRequest(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -99,6 +104,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates a SessionNotFound protocol status.
|
/// Creates a SessionNotFound protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.SessionNotFound"/>.</returns>
|
||||||
public static ProtocolStatus SessionNotFound(string message)
|
public static ProtocolStatus SessionNotFound(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -112,6 +118,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates a SessionNotReady protocol status.
|
/// Creates a SessionNotReady protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.SessionNotReady"/>.</returns>
|
||||||
public static ProtocolStatus SessionNotReady(string message)
|
public static ProtocolStatus SessionNotReady(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -125,6 +132,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates a WorkerUnavailable protocol status.
|
/// Creates a WorkerUnavailable protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.WorkerUnavailable"/>.</returns>
|
||||||
public static ProtocolStatus WorkerUnavailable(string message)
|
public static ProtocolStatus WorkerUnavailable(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -138,6 +146,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates a Timeout protocol status.
|
/// Creates a Timeout protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Timeout"/>.</returns>
|
||||||
public static ProtocolStatus Timeout(string message)
|
public static ProtocolStatus Timeout(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -151,6 +160,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates a Canceled protocol status.
|
/// Creates a Canceled protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Canceled"/>.</returns>
|
||||||
public static ProtocolStatus Canceled(string message)
|
public static ProtocolStatus Canceled(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -164,6 +174,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates a ProtocolViolation protocol status.
|
/// Creates a ProtocolViolation protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.ProtocolViolation"/>.</returns>
|
||||||
public static ProtocolStatus ProtocolViolation(string message)
|
public static ProtocolStatus ProtocolViolation(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Metrics;
|
|||||||
|
|
||||||
public sealed class GatewayMetrics : IDisposable
|
public sealed class GatewayMetrics : IDisposable
|
||||||
{
|
{
|
||||||
public const string MeterName = "MxGateway.Server";
|
public const string MeterName = "ZB.MOM.WW.MxGateway";
|
||||||
|
|
||||||
private readonly object _syncRoot = new();
|
private readonly object _syncRoot = new();
|
||||||
private readonly Meter _meter;
|
private readonly Meter _meter;
|
||||||
@@ -68,9 +68,9 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
_heartbeatFailuresCounter = _meter.CreateCounter<long>("mxgateway.heartbeats.failed");
|
_heartbeatFailuresCounter = _meter.CreateCounter<long>("mxgateway.heartbeats.failed");
|
||||||
_streamDisconnectsCounter = _meter.CreateCounter<long>("mxgateway.grpc.streams.disconnected");
|
_streamDisconnectsCounter = _meter.CreateCounter<long>("mxgateway.grpc.streams.disconnected");
|
||||||
_retryAttemptsCounter = _meter.CreateCounter<long>("mxgateway.retries.attempted");
|
_retryAttemptsCounter = _meter.CreateCounter<long>("mxgateway.retries.attempted");
|
||||||
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "ms");
|
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
|
||||||
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "ms");
|
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
|
||||||
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "ms");
|
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
|
||||||
|
|
||||||
_meter.CreateObservableGauge("mxgateway.sessions.open", GetOpenSessions);
|
_meter.CreateObservableGauge("mxgateway.sessions.open", GetOpenSessions);
|
||||||
_meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning);
|
_meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning);
|
||||||
@@ -144,7 +144,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
_workersRunning++;
|
_workersRunning++;
|
||||||
}
|
}
|
||||||
|
|
||||||
_workerStartupLatencyHistogram.Record(startupDuration.TotalMilliseconds);
|
_workerStartupLatencyHistogram.Record(startupDuration.TotalSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -208,7 +208,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
|
|
||||||
KeyValuePair<string, object?> methodTag = new("method", method);
|
KeyValuePair<string, object?> methodTag = new("method", method);
|
||||||
_commandsSucceededCounter.Add(1, methodTag);
|
_commandsSucceededCounter.Add(1, methodTag);
|
||||||
_commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag);
|
_commandLatencyHistogram.Record(duration.TotalSeconds, methodTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -228,7 +228,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
KeyValuePair<string, object?> methodTag = new("method", method);
|
KeyValuePair<string, object?> methodTag = new("method", method);
|
||||||
KeyValuePair<string, object?> categoryTag = new("category", category);
|
KeyValuePair<string, object?> categoryTag = new("category", category);
|
||||||
_commandsFailedCounter.Add(1, methodTag, categoryTag);
|
_commandsFailedCounter.Add(1, methodTag, categoryTag);
|
||||||
_commandLatencyHistogram.Record(duration.TotalMilliseconds, methodTag, categoryTag);
|
_commandLatencyHistogram.Record(duration.TotalSeconds, methodTag, categoryTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -255,7 +255,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
public void RecordEventStreamSend(string family, TimeSpan duration)
|
public void RecordEventStreamSend(string family, TimeSpan duration)
|
||||||
{
|
{
|
||||||
_eventStreamSendLatencyHistogram.Record(
|
_eventStreamSendLatencyHistogram.Record(
|
||||||
duration.TotalMilliseconds,
|
duration.TotalSeconds,
|
||||||
new KeyValuePair<string, object?>("family", family));
|
new KeyValuePair<string, object?>("family", family));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +380,7 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a snapshot of all current metric values.
|
/// Returns a snapshot of all current metric values.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A consistent snapshot of the current metric counters and gauges.</returns>
|
||||||
public GatewayMetricsSnapshot GetSnapshot()
|
public GatewayMetricsSnapshot GetSnapshot()
|
||||||
{
|
{
|
||||||
lock (_syncRoot)
|
lock (_syncRoot)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Best-effort <see cref="IAuditWriter"/> over the MxGateway-owned
|
||||||
|
/// <see cref="SqliteCanonicalAuditStore"/>. It honours the canonical
|
||||||
|
/// <see cref="IAuditWriter"/> contract: a failed audit write is swallowed and logged
|
||||||
|
/// rather than propagated, so it can never abort the user-facing action that produced it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is the single sink through which ALL MxGateway audit flows — the library admin
|
||||||
|
/// verbs (via <see cref="CanonicalForwardingApiKeyAuditStore"/>) and the gateway's own
|
||||||
|
/// dashboard / constraint-denial producers, which write canonical events directly. The
|
||||||
|
/// best-effort wrapping here also closes the gap that the library's
|
||||||
|
/// <c>SqliteApiKeyAuditStore.AppendAsync</c> propagated exceptions.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CanonicalAuditWriter(
|
||||||
|
SqliteCanonicalAuditStore store,
|
||||||
|
ILogger<CanonicalAuditWriter> logger) : IAuditWriter
|
||||||
|
{
|
||||||
|
/// <summary>Persists the audit event to the canonical store; swallows and logs any write failure rather than propagating it.</summary>
|
||||||
|
/// <param name="auditEvent">The audit event to persist.</param>
|
||||||
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(auditEvent);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await store.InsertAsync(auditEvent, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
// Best-effort: a failed audit write must never abort the action that produced it.
|
||||||
|
// Swallow everything (including OperationCanceledException) and log for diagnosis.
|
||||||
|
logger.LogWarning(
|
||||||
|
exception,
|
||||||
|
"Failed to persist audit event {EventId} (action {Action}); audit write is best-effort and was suppressed.",
|
||||||
|
auditEvent.EventId,
|
||||||
|
auditEvent.Action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adapter that overrides the shared library's <see cref="IApiKeyAuditStore"/> so that
|
||||||
|
/// library-emitted API-key audit events (CLI / admin verbs from
|
||||||
|
/// <c>ApiKeyAdminCommands</c>) are canonicalized onto <see cref="AuditEvent"/> and routed
|
||||||
|
/// through the gateway's <see cref="IAuditWriter"/> into the canonical
|
||||||
|
/// <c>audit_event</c> store.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Overriding the registered <see cref="IApiKeyAuditStore"/> is the ONLY way to
|
||||||
|
/// canonicalize the library-internal <c>ApiKeyAdminCommands</c> events, since that type
|
||||||
|
/// cannot be edited. <see cref="ListRecentAsync"/> reads back from the canonical store
|
||||||
|
/// and maps each <see cref="AuditEvent"/> to an <see cref="ApiKeyAuditEntry"/> so the
|
||||||
|
/// existing dashboard "recent audit" view (and the CLI/store tests) keep working through
|
||||||
|
/// this same seam, unchanged.
|
||||||
|
/// <para>
|
||||||
|
/// The library's own <c>api_key_audit</c> table is left in place but UNUSED — nothing
|
||||||
|
/// writes to it once this adapter overrides the library's <c>SqliteApiKeyAuditStore</c>
|
||||||
|
/// registration.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class CanonicalForwardingApiKeyAuditStore(
|
||||||
|
IAuditWriter auditWriter,
|
||||||
|
SqliteCanonicalAuditStore store) : IApiKeyAuditStore
|
||||||
|
{
|
||||||
|
/// <summary>The canonical <see cref="AuditEvent.Category"/> assigned to API-key events.</summary>
|
||||||
|
public const string ApiKeyCategory = "ApiKey";
|
||||||
|
|
||||||
|
/// <summary>Actor used for the library's keyless <c>init-db</c> event.</summary>
|
||||||
|
private const string SystemActor = "system";
|
||||||
|
|
||||||
|
/// <summary>Actor used for any other keyless (CLI-originated) library event.</summary>
|
||||||
|
private const string CliActor = "cli";
|
||||||
|
|
||||||
|
/// <summary>The library event type that denotes a constraint denial.</summary>
|
||||||
|
private const string ConstraintDeniedEventType = "constraint-denied";
|
||||||
|
|
||||||
|
/// <summary>The library's keyless schema-init event type.</summary>
|
||||||
|
private const string InitDbEventType = "init-db";
|
||||||
|
|
||||||
|
/// <summary>Converts the library audit entry to a canonical <see cref="AuditEvent"/> and forwards it through the gateway's audit writer.</summary>
|
||||||
|
/// <param name="entry">The API key audit entry to append.</param>
|
||||||
|
/// <param name="ct">Token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entry);
|
||||||
|
|
||||||
|
AuditEvent auditEvent = new()
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = entry.CreatedUtc,
|
||||||
|
// Keyless library events: init-db is system-originated; any other keyless event
|
||||||
|
// is a CLI/admin verb run without an authenticated principal.
|
||||||
|
Actor = entry.KeyId
|
||||||
|
?? (entry.EventType == InitDbEventType ? SystemActor : CliActor),
|
||||||
|
Action = entry.EventType,
|
||||||
|
Outcome = entry.EventType == ConstraintDeniedEventType
|
||||||
|
? AuditOutcome.Denied
|
||||||
|
: AuditOutcome.Success,
|
||||||
|
Category = ApiKeyCategory,
|
||||||
|
Target = entry.KeyId,
|
||||||
|
SourceNode = entry.RemoteAddress,
|
||||||
|
CorrelationId = null,
|
||||||
|
DetailsJson = WrapDetails(entry.Details),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Best-effort: IAuditWriter swallows/logs failures, so this never throws.
|
||||||
|
await auditWriter.WriteAsync(auditEvent, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads recent audit events from the canonical store and maps them back to library-compatible <see cref="ApiKeyAuditEntry"/> records.</summary>
|
||||||
|
/// <param name="limit">Maximum number of entries to return.</param>
|
||||||
|
/// <param name="ct">Token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task that resolves to the most recent audit entries, up to <paramref name="limit"/> items.</returns>
|
||||||
|
public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyList<AuditEvent> events = await store.ListRecentAsync(limit, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
ApiKeyAuditEntry[] entries = new ApiKeyAuditEntry[events.Count];
|
||||||
|
for (int index = 0; index < events.Count; index++)
|
||||||
|
{
|
||||||
|
AuditEvent auditEvent = events[index];
|
||||||
|
entries[index] = new ApiKeyAuditEntry(
|
||||||
|
KeyId: auditEvent.Actor switch
|
||||||
|
{
|
||||||
|
// Keyless library events were mapped to the system/cli sentinel actors on the
|
||||||
|
// way in; map them back to a null KeyId so the dashboard view is faithful.
|
||||||
|
SystemActor or CliActor => null,
|
||||||
|
string actor => actor,
|
||||||
|
},
|
||||||
|
EventType: auditEvent.Action,
|
||||||
|
RemoteAddress: auditEvent.SourceNode,
|
||||||
|
CreatedUtc: auditEvent.OccurredAtUtc,
|
||||||
|
Details: UnwrapDetails(auditEvent.DetailsJson));
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps a free-form library detail string into the canonical
|
||||||
|
/// <c>{"detail": "<escaped>"}</c> JSON envelope, or null when there is no detail.
|
||||||
|
/// </summary>
|
||||||
|
private static string? WrapDetails(string? details)
|
||||||
|
{
|
||||||
|
if (details is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Serialize(new Dictionary<string, string> { ["detail"] = details });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unwraps the canonical detail envelope back to the original free-form string. Falls
|
||||||
|
/// back to the raw JSON when it is not a recognised <c>{"detail": ...}</c> envelope, so
|
||||||
|
/// directly-emitted canonical events (whose DetailsJson is richer) still surface text.
|
||||||
|
/// </summary>
|
||||||
|
private static string? UnwrapDetails(string? detailsJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(detailsJson))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using JsonDocument document = JsonDocument.Parse(detailsJson);
|
||||||
|
if (document.RootElement.ValueKind == JsonValueKind.Object
|
||||||
|
&& document.RootElement.TryGetProperty("detail", out JsonElement detail)
|
||||||
|
&& detail.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
return detail.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Not JSON we recognise; surface the raw payload below.
|
||||||
|
}
|
||||||
|
|
||||||
|
return detailsJson;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP-context-backed implementation of <see cref="IAuditActorAccessor"/> that reads the
|
||||||
|
/// dashboard operator's identity from the current <see cref="IHttpContextAccessor"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Claim resolution order:
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><see cref="ZbClaimTypes.Username"/> ("zb:username") — the canonical LDAP login name.</item>
|
||||||
|
/// <item><see cref="ClaimsPrincipal.Identity"/>.<see cref="System.Security.Principal.IIdentity.Name"/> — framework fallback (= <see cref="ZbClaimTypes.Name"/> = <see cref="ClaimTypes.Name"/> = display name).</item>
|
||||||
|
/// <item><see cref="ZbClaimTypes.Name"/> — explicit fallback matching the claim emitted by <c>DashboardAuthenticator.CreatePrincipal</c>.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Returns <see langword="null"/> when there is no HTTP context or the user is not authenticated.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class HttpAuditActorAccessor(IHttpContextAccessor httpContextAccessor) : IAuditActorAccessor
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? CurrentActor
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
|
||||||
|
if (user?.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer the canonical login-username claim (set by DashboardAuthenticator).
|
||||||
|
string? username = user.FindFirstValue(ZbClaimTypes.Username);
|
||||||
|
if (!string.IsNullOrWhiteSpace(username))
|
||||||
|
{
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Framework fallback: Identity.Name is driven by the ClaimsIdentity nameClaimType,
|
||||||
|
// which DashboardAuthenticator sets to ZbClaimTypes.Name (= ClaimTypes.Name = display name).
|
||||||
|
string? identityName = user.Identity?.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(identityName))
|
||||||
|
{
|
||||||
|
return identityName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final explicit fallback — ZbClaimTypes.Name claim value directly.
|
||||||
|
return user.FindFirstValue(ZbClaimTypes.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the current actor name for use in audit events.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Implementations resolve the actor from the ambient request context. For the dashboard
|
||||||
|
/// this is the authenticated LDAP operator; for non-HTTP contexts (gRPC, CLI) the caller
|
||||||
|
/// provides the actor directly and this seam is not used.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IAuditActorAccessor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current actor's username, or <see langword="null"/> when there is no
|
||||||
|
/// authenticated principal in scope (e.g. an anonymous or unauthenticated request).
|
||||||
|
/// </summary>
|
||||||
|
string? CurrentActor { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MxGateway-owned, append-only SQLite store for canonical
|
||||||
|
/// <see cref="AuditEvent"/>s. It writes to a NEW <c>audit_event</c> table in the
|
||||||
|
/// SAME database file as the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> stores: both share
|
||||||
|
/// the library's <see cref="AuthSqliteConnectionFactory"/> (so they target the same
|
||||||
|
/// <c>ApiKeyOptions.SqlitePath</c> with the same WAL/busy-timeout connection config).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This store is the canonical sink for ALL MxGateway audit. The library's own
|
||||||
|
/// <c>api_key_audit</c> table is left in place but UNUSED after adoption — the library's
|
||||||
|
/// <c>IApiKeyAuditStore</c> registration is overridden by
|
||||||
|
/// <see cref="CanonicalForwardingApiKeyAuditStore"/>, which forwards onto this store via
|
||||||
|
/// <see cref="CanonicalAuditWriter"/>. The library's <c>schema_version</c> /
|
||||||
|
/// <c>api_key_audit</c> tables are not touched here; the <c>audit_event</c> table is
|
||||||
|
/// created idempotently (<c>CREATE TABLE IF NOT EXISTS</c>) on each write so it
|
||||||
|
/// self-bootstraps regardless of migration ordering.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connectionFactory)
|
||||||
|
{
|
||||||
|
private const string CreateTableSql =
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_event (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
occurred_at_utc TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
outcome TEXT NOT NULL,
|
||||||
|
category TEXT NULL,
|
||||||
|
target TEXT NULL,
|
||||||
|
source_node TEXT NULL,
|
||||||
|
correlation_id TEXT NULL,
|
||||||
|
details_json TEXT NULL
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
|
||||||
|
/// <summary>Inserts a canonical audit event into the <c>audit_event</c> table.</summary>
|
||||||
|
/// <param name="auditEvent">The canonical event to persist.</param>
|
||||||
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(auditEvent);
|
||||||
|
|
||||||
|
await using SqliteConnection connection =
|
||||||
|
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await using SqliteCommand command = connection.CreateCommand();
|
||||||
|
command.CommandText =
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_event
|
||||||
|
(event_id, occurred_at_utc, actor, action, outcome,
|
||||||
|
category, target, source_node, correlation_id, details_json)
|
||||||
|
VALUES
|
||||||
|
($event_id, $occurred_at_utc, $actor, $action, $outcome,
|
||||||
|
$category, $target, $source_node, $correlation_id, $details_json);
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$event_id", auditEvent.EventId.ToString());
|
||||||
|
command.Parameters.AddWithValue("$occurred_at_utc", auditEvent.OccurredAtUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||||
|
command.Parameters.AddWithValue("$actor", auditEvent.Actor);
|
||||||
|
command.Parameters.AddWithValue("$action", auditEvent.Action);
|
||||||
|
command.Parameters.AddWithValue("$outcome", auditEvent.Outcome.ToString());
|
||||||
|
command.Parameters.AddWithValue("$category", (object?)auditEvent.Category ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("$target", (object?)auditEvent.Target ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("$source_node", (object?)auditEvent.SourceNode ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("$correlation_id", (object?)auditEvent.CorrelationId?.ToString() ?? DBNull.Value);
|
||||||
|
command.Parameters.AddWithValue("$details_json", (object?)auditEvent.DetailsJson ?? DBNull.Value);
|
||||||
|
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the most recent canonical audit events, newest first.</summary>
|
||||||
|
/// <param name="limit">Maximum number of events to return.</param>
|
||||||
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task that resolves to the most recent audit events, up to <paramref name="limit"/>.</returns>
|
||||||
|
public async Task<IReadOnlyList<AuditEvent>> ListRecentAsync(int limit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (limit <= 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
await using SqliteConnection connection =
|
||||||
|
await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await EnsureTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await using SqliteCommand command = connection.CreateCommand();
|
||||||
|
command.CommandText =
|
||||||
|
"""
|
||||||
|
SELECT event_id, occurred_at_utc, actor, action, outcome,
|
||||||
|
category, target, source_node, correlation_id, details_json
|
||||||
|
FROM audit_event
|
||||||
|
ORDER BY rowid DESC
|
||||||
|
LIMIT $limit;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$limit", limit);
|
||||||
|
|
||||||
|
List<AuditEvent> events = [];
|
||||||
|
|
||||||
|
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
events.Add(new AuditEvent
|
||||||
|
{
|
||||||
|
EventId = Guid.Parse(reader.GetString(0)),
|
||||||
|
OccurredAtUtc = ParseUtc(reader.GetString(1)),
|
||||||
|
Actor = reader.GetString(2),
|
||||||
|
Action = reader.GetString(3),
|
||||||
|
Outcome = Enum.Parse<AuditOutcome>(reader.GetString(4)),
|
||||||
|
Category = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||||
|
Target = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||||
|
SourceNode = reader.IsDBNull(7) ? null : reader.GetString(7),
|
||||||
|
CorrelationId = reader.IsDBNull(8) ? null : Guid.Parse(reader.GetString(8)),
|
||||||
|
DetailsJson = reader.IsDBNull(9) ? null : reader.GetString(9),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureTableAsync(SqliteConnection connection, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using SqliteCommand command = connection.CreateCommand();
|
||||||
|
command.CommandText = CreateTableSql;
|
||||||
|
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset ParseUtc(string value) =>
|
||||||
|
DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||||
|
}
|
||||||
@@ -1,15 +1,19 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
|
||||||
|
using ZB.MOM.WW.Auth.ApiKeys.Admin;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Executes API key administration commands from the CLI.
|
/// Executes API key administration commands from the CLI.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ApiKeyAdminCliRunner(
|
/// <remarks>
|
||||||
IAuthStoreMigrator migrator,
|
/// The create/revoke/rotate/list/init-db verbs (secret generation, peppered hashing, token
|
||||||
IApiKeyAdminStore adminStore,
|
/// assembly and per-action audit) are delegated to the shared
|
||||||
IApiKeyAuditStore auditStore,
|
/// <see cref="ApiKeyAdminCommands"/>. This runner adapts the gateway's strongly-typed command and
|
||||||
IApiKeySecretHasher hasher)
|
/// output DTOs (which carry <see cref="ApiKeyConstraints"/>) onto the library's JSON-based contract.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -22,6 +26,7 @@ public sealed class ApiKeyAdminCliRunner(
|
|||||||
/// <param name="command">API key administration command to execute.</param>
|
/// <param name="command">API key administration command to execute.</param>
|
||||||
/// <param name="output">Text writer for command output.</param>
|
/// <param name="output">Text writer for command output.</param>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the exit code (always 0 on success).</returns>
|
||||||
public async Task<int> RunAsync(
|
public async Task<int> RunAsync(
|
||||||
ApiKeyAdminCommand command,
|
ApiKeyAdminCommand command,
|
||||||
TextWriter output,
|
TextWriter output,
|
||||||
@@ -44,8 +49,7 @@ public sealed class ApiKeyAdminCliRunner(
|
|||||||
|
|
||||||
private async Task<ApiKeyAdminOutput> InitDbAsync(CancellationToken cancellationToken)
|
private async Task<ApiKeyAdminOutput> InitDbAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
|
||||||
await AppendAuditAsync(null, "init-db", null, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return new ApiKeyAdminOutput("init-db", "initialized", null, []);
|
return new ApiKeyAdminOutput("init-db", "initialized", null, []);
|
||||||
}
|
}
|
||||||
@@ -54,33 +58,26 @@ public sealed class ApiKeyAdminCliRunner(
|
|||||||
ApiKeyAdminCommand command,
|
ApiKeyAdminCommand command,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
// The shared command set requires the schema to exist; init-db is idempotent.
|
||||||
|
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
string keyId = Required(command.KeyId);
|
string keyId = Required(command.KeyId);
|
||||||
string secret = ApiKeySecretGenerator.Generate();
|
CreateKeyResult created = await commands.CreateKeyAsync(
|
||||||
string apiKey = FormatApiKey(keyId, secret);
|
keyId,
|
||||||
|
Required(command.DisplayName),
|
||||||
await adminStore.CreateAsync(
|
command.Scopes,
|
||||||
new ApiKeyCreateRequest(
|
ApiKeyConstraintSerializer.Serialize(command.Constraints),
|
||||||
KeyId: keyId,
|
remoteAddress: null,
|
||||||
KeyPrefix: $"mxgw_{keyId}",
|
|
||||||
SecretHash: hasher.HashSecret(secret),
|
|
||||||
DisplayName: Required(command.DisplayName),
|
|
||||||
Scopes: command.Scopes,
|
|
||||||
Constraints: command.Constraints,
|
|
||||||
CreatedUtc: DateTimeOffset.UtcNow),
|
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
await AppendAuditAsync(keyId, "create-key", null, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return new ApiKeyAdminOutput("create-key", "created", apiKey, []);
|
return new ApiKeyAdminOutput("create-key", "created", created.Token, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ApiKeyAdminOutput> ListKeysAsync(CancellationToken cancellationToken)
|
private async Task<ApiKeyAdminOutput> ListKeysAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
|
||||||
IReadOnlyList<ApiKeyRecord> keys = await adminStore.ListAsync(cancellationToken).ConfigureAwait(false);
|
IReadOnlyList<ApiKeyListItem> keys = await commands.ListKeysAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await AppendAuditAsync(null, "list-keys", null, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
return new ApiKeyAdminOutput(
|
return new ApiKeyAdminOutput(
|
||||||
"list-keys",
|
"list-keys",
|
||||||
@@ -93,35 +90,28 @@ public sealed class ApiKeyAdminCliRunner(
|
|||||||
ApiKeyAdminCommand command,
|
ApiKeyAdminCommand command,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
string keyId = Required(command.KeyId);
|
string keyId = Required(command.KeyId);
|
||||||
bool revoked = await adminStore.RevokeAsync(keyId, DateTimeOffset.UtcNow, cancellationToken)
|
KeyActionResult result = await commands.RevokeKeyAsync(keyId, remoteAddress: null, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(keyId, "revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", cancellationToken)
|
return new ApiKeyAdminOutput("revoke-key", result.Succeeded ? "revoked" : "not-found-or-already-revoked", null, []);
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
return new ApiKeyAdminOutput("revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", null, []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ApiKeyAdminOutput> RotateKeyAsync(
|
private async Task<ApiKeyAdminOutput> RotateKeyAsync(
|
||||||
ApiKeyAdminCommand command,
|
ApiKeyAdminCommand command,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
string keyId = Required(command.KeyId);
|
string keyId = Required(command.KeyId);
|
||||||
string secret = ApiKeySecretGenerator.Generate();
|
CreateKeyResult rotated = await commands.RotateKeyAsync(keyId, remoteAddress: null, cancellationToken)
|
||||||
string apiKey = FormatApiKey(keyId, secret);
|
|
||||||
|
|
||||||
bool rotated = await adminStore.RotateAsync(keyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
await AppendAuditAsync(keyId, "rotate-key", rotated ? "rotated" : "not-found", cancellationToken)
|
bool succeeded = rotated.Token is not null;
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
return new ApiKeyAdminOutput("rotate-key", rotated ? "rotated" : "not-found", rotated ? apiKey : null, []);
|
return new ApiKeyAdminOutput("rotate-key", succeeded ? "rotated" : "not-found", rotated.Token, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task WriteOutputAsync(
|
private static async Task WriteOutputAsync(
|
||||||
@@ -150,40 +140,19 @@ public sealed class ApiKeyAdminCliRunner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AppendAuditAsync(
|
private static ApiKeyAdminListedKey ToListedKey(ApiKeyListItem key)
|
||||||
string? keyId,
|
|
||||||
string eventType,
|
|
||||||
string? details,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
await auditStore.AppendAsync(
|
|
||||||
new ApiKeyAuditEntry(
|
|
||||||
KeyId: keyId,
|
|
||||||
EventType: eventType,
|
|
||||||
RemoteAddress: null,
|
|
||||||
Details: details),
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ApiKeyAdminListedKey ToListedKey(ApiKeyRecord key)
|
|
||||||
{
|
{
|
||||||
return new ApiKeyAdminListedKey(
|
return new ApiKeyAdminListedKey(
|
||||||
KeyId: key.KeyId,
|
KeyId: key.KeyId,
|
||||||
KeyPrefix: key.KeyPrefix,
|
KeyPrefix: key.KeyPrefix,
|
||||||
DisplayName: key.DisplayName,
|
DisplayName: key.DisplayName,
|
||||||
Scopes: key.Scopes,
|
Scopes: key.Scopes,
|
||||||
Constraints: key.Constraints,
|
Constraints: ApiKeyConstraintSerializer.Deserialize(key.ConstraintsJson),
|
||||||
CreatedUtc: key.CreatedUtc,
|
CreatedUtc: key.CreatedUtc,
|
||||||
LastUsedUtc: key.LastUsedUtc,
|
LastUsedUtc: key.LastUsedUtc,
|
||||||
RevokedUtc: key.RevokedUtc);
|
RevokedUtc: key.RevokedUtc);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatApiKey(string keyId, string secret)
|
|
||||||
{
|
|
||||||
return $"mxgw_{keyId}_{secret}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Required(string? value)
|
private static string Required(string? value)
|
||||||
{
|
{
|
||||||
return value ?? throw new InvalidOperationException("Required command value was not provided.");
|
return value ?? throw new InvalidOperationException("Required command value was not provided.");
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public sealed record ApiKeyAdminParseResult(
|
|||||||
string? Error)
|
string? Error)
|
||||||
{
|
{
|
||||||
/// <summary>Returns a result indicating the input was not an API key command.</summary>
|
/// <summary>Returns a result indicating the input was not an API key command.</summary>
|
||||||
|
/// <returns>A parse result with <see cref="IsApiKeyCommand"/> set to false.</returns>
|
||||||
public static ApiKeyAdminParseResult NotApiKeyCommand()
|
public static ApiKeyAdminParseResult NotApiKeyCommand()
|
||||||
{
|
{
|
||||||
return new ApiKeyAdminParseResult(false, null, null);
|
return new ApiKeyAdminParseResult(false, null, null);
|
||||||
@@ -13,6 +14,7 @@ public sealed record ApiKeyAdminParseResult(
|
|||||||
|
|
||||||
/// <summary>Returns a successful parse result with the parsed API key command.</summary>
|
/// <summary>Returns a successful parse result with the parsed API key command.</summary>
|
||||||
/// <param name="command">Parsed API key administration command.</param>
|
/// <param name="command">Parsed API key administration command.</param>
|
||||||
|
/// <returns>A parse result with <see cref="IsApiKeyCommand"/> set to true and the command populated.</returns>
|
||||||
public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command)
|
public static ApiKeyAdminParseResult Success(ApiKeyAdminCommand command)
|
||||||
{
|
{
|
||||||
return new ApiKeyAdminParseResult(true, command, null);
|
return new ApiKeyAdminParseResult(true, command, null);
|
||||||
@@ -20,6 +22,7 @@ public sealed record ApiKeyAdminParseResult(
|
|||||||
|
|
||||||
/// <summary>Returns a parse result with the specified error message.</summary>
|
/// <summary>Returns a parse result with the specified error message.</summary>
|
||||||
/// <param name="error">Error message describing the parse failure.</param>
|
/// <param name="error">Error message describing the parse failure.</param>
|
||||||
|
/// <returns>A parse result with <see cref="IsApiKeyCommand"/> set to true and the error message populated.</returns>
|
||||||
public static ApiKeyAdminParseResult Fail(string error)
|
public static ApiKeyAdminParseResult Fail(string error)
|
||||||
{
|
{
|
||||||
return new ApiKeyAdminParseResult(true, null, error);
|
return new ApiKeyAdminParseResult(true, null, error);
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed record ApiKeyAuditEntry(
|
|
||||||
string? KeyId,
|
|
||||||
string EventType,
|
|
||||||
string? RemoteAddress,
|
|
||||||
string? Details);
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
|
||||||
|
|
||||||
public sealed record ApiKeyAuditRecord(
|
|
||||||
long AuditId,
|
|
||||||
string? KeyId,
|
|
||||||
string EventType,
|
|
||||||
string? RemoteAddress,
|
|
||||||
DateTimeOffset CreatedUtc,
|
|
||||||
string? Details);
|
|
||||||
@@ -12,6 +12,7 @@ public static class ApiKeyConstraintSerializer
|
|||||||
|
|
||||||
/// <summary>Serializes API key constraints to JSON, or returns null if the constraints are empty.</summary>
|
/// <summary>Serializes API key constraints to JSON, or returns null if the constraints are empty.</summary>
|
||||||
/// <param name="constraints">The constraints to serialize.</param>
|
/// <param name="constraints">The constraints to serialize.</param>
|
||||||
|
/// <returns>A JSON string representing the constraints, or <see langword="null"/> if empty.</returns>
|
||||||
public static string? Serialize(ApiKeyConstraints constraints)
|
public static string? Serialize(ApiKeyConstraints constraints)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(constraints);
|
ArgumentNullException.ThrowIfNull(constraints);
|
||||||
@@ -20,6 +21,7 @@ public static class ApiKeyConstraintSerializer
|
|||||||
|
|
||||||
/// <summary>Deserializes API key constraints from JSON, or returns empty constraints if JSON is null or whitespace.</summary>
|
/// <summary>Deserializes API key constraints from JSON, or returns empty constraints if JSON is null or whitespace.</summary>
|
||||||
/// <param name="json">The JSON string to deserialize.</param>
|
/// <param name="json">The JSON string to deserialize.</param>
|
||||||
|
/// <returns>The deserialized <see cref="ApiKeyConstraints"/>, or <see cref="ApiKeyConstraints.Empty"/> when <paramref name="json"/> is null or whitespace.</returns>
|
||||||
public static ApiKeyConstraints Deserialize(string? json)
|
public static ApiKeyConstraints Deserialize(string? json)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user