Compare commits

...

37 Commits

Author SHA1 Message Date
Joseph Doherty a1156960b9 docs: add missing XML doc comments across gateway, worker, and .NET client
Resolves 1113 documentation-completeness gaps flagged by CommentChecker
(MissingReturns, MissingInheritDoc, InheritDocMisused, MissingDoc,
MissingParam, RedundantInheritDoc) so the API surface is fully documented
and the analyzer scan is clean. Doc comments only; no code changes.
2026-06-03 12:33:53 -04:00
Joseph Doherty 5539ec8542 chore(dashboard): prune dead sidebar + orphaned login CSS from site.css
Removed the dead .sidebar nav block (replaced by the kit's .side-rail shell) and
the orphaned .dashboard-login/.login-card rules (the /login page now uses the
kit's <LoginCard>). Kept .app-bar (still used by the /denied page header) and the
.chip white-space override (emitted by StatusPill); corrected the now-stale
app-bar comment. 106 lines removed; builds clean.
2026-06-03 04:37:23 -04:00
Joseph Doherty 73e54e252d feat(dashboard): Blazor LoginCard page reusing the hardened /login endpoint 2026-06-03 03:56:51 -04:00
Joseph Doherty 70d959bd9b refactor(dashboard): StatusBadge delegates to ZB.MOM.WW.Theme StatusPill 2026-06-03 03:51:45 -04:00
Joseph Doherty 0c5b796e2e feat(dashboard): split MainLayout into ZB.MOM.WW.Theme ThemeShell + kit nav 2026-06-03 03:49:34 -04:00
Joseph Doherty 47dc9d865f refactor(dashboard): drop vendored theme.css/fonts/nav-state.js; keep app-only CSS in site.css
Repoint the server-rendered sign-in/fallback HTML (DashboardEndpointRouteBuilderExtensions) from /css/theme.css to the kit's _content/ZB.MOM.WW.Theme/css/{theme,layout}.css, mirroring ThemeHead, since that static page cannot use the Razor component.
2026-06-03 03:46:37 -04:00
Joseph Doherty 4f757e3c0c feat(dashboard): use ZB.MOM.WW.Theme ThemeHead + ThemeScripts 2026-06-03 03:44:18 -04:00
Joseph Doherty 2f0ee4c961 build(server): reference ZB.MOM.WW.Theme 0.2.0 2026-06-03 03:43:17 -04:00
Joseph Doherty 0859d47f75 feat(audit): MxGateway IAuditActorAccessor + dashboard audit Actor = operator principal (keyId→Target) (Phase 3)
Introduce IAuditActorAccessor seam + HttpAuditActorAccessor impl (reads ZbClaimTypes.Username
from IHttpContextAccessor; falls back to Identity.Name / ZbClaimTypes.Name; null when
unauthenticated). Register in DI via DashboardServiceCollectionExtensions.

Wire DashboardApiKeyManagementService: WriteDashboardAuditAsync now accepts the ClaimsPrincipal
user already in scope at each call site; ResolveOperatorActor extracts ZbClaimTypes.Username
(preferred) or Identity.Name. All four dashboard-* events now emit Actor = LDAP operator
username and Target = managed keyId, fixing the semantic gap where both fields held the keyId.

ConstraintEnforcer (gRPC / API-key actor) and CanonicalForwardingApiKeyAuditStore (CLI /
"system"/"cli" fallback) are unchanged.

Tests: DashboardApiKeyManagementServiceTests updated — CreateAuthorizedUser adds ZbClaimTypes.Username
("alice"), all dashboard-* audit assertions updated to Actor = "alice" / Target = "operator01";
new CreateAsync_AuthorizedUser_CanonicalAuditEventHasOperatorAsActorAndKeyIdAsTarget verifies the
canonical AuditEvent directly. New HttpAuditActorAccessorTests (4 cases: username claim, Identity.Name
fallback, unauthenticated → null, no context → null). ConstraintEnforcer tests still assert API-key/anonymous actor.
2026-06-02 15:25:39 -04:00
Joseph Doherty 7ea8358c06 feat(audit): MxGateway local producers (dashboard + constraint-denial) emit canonical AuditEvent with Target/CorrelationId (Task 2.3 #6) 2026-06-02 10:13:54 -04:00
Joseph Doherty a5944bbe5d feat(audit): MxGateway canonical SQLite audit_event store + IAuditWriter + IApiKeyAuditStore->canonical adapter (Task 2.3) 2026-06-02 10:10:38 -04:00
Joseph Doherty 04bce3ff9f feat(auth)!: MxGateway canonical dashboard roles — Admin→Administrator (Task 1.7)
Standardize the dashboard role VALUE on the canonical six: Admin→Administrator
(Viewer unchanged). Pure value rename via DashboardRoles.Admin constant +
appsettings GroupToRole; the GatewayOptionsValidator allowed-set/message track
the constant so they now require 'Administrator' or 'Viewer'. Enforcement is
unchanged — Administrator authorizes exactly what Admin did.

Dashboard roles are derived at login from LDAP groups via GroupToRole and are
never persisted to the SQLite auth store, so no DB migration/seed change.

UNTOUCHED: the separate gRPC API-key scope GatewayScopes.Admin = "admin"
(lowercase) and every "admin" scope literal — a distinct data-plane system.
2026-06-02 07:22:42 -04:00
Joseph Doherty 9572045787 chore(auth): MxGateway unify dev LDAP base DN to dc=zb,dc=local (Task 1.6) 2026-06-02 06:44:38 -04:00
Joseph Doherty 7e1af37eb1 feat(auth): MxGateway dashboard adopt ZbClaimTypes + ZbCookieDefaults, keep cookie name (Task 1.5)
- DashboardAuthenticator.CreatePrincipal: emit ZbClaimTypes.Username ("zb:username") with
  the login username, ZbClaimTypes.DisplayName ("zb:displayname") with the display name,
  ZbClaimTypes.Name (== ClaimTypes.Name) for Identity.Name resolution, ZbClaimTypes.Role
  (== ClaimTypes.Role) for IsInRole/[Authorize]. Keep ClaimTypes.NameIdentifier for back-compat
  read-sites; keep mxgateway:ldap_group unchanged (MxGateway-specific, no ZbClaimType for groups).
  ClaimsIdentity built with nameType=ZbClaimTypes.Name, roleType=ZbClaimTypes.Role.
- DashboardServiceCollectionExtensions.AddGatewayDashboard: route cookie hardening through
  ZbCookieDefaults.Apply(requireHttps:true, idleTimeout:8h); set cookie name/path/redirects
  after Apply; PostConfigure still overrides SecurePolicy per RequireHttpsCookie setting.
- DashboardAuthenticatorTests: add AuthenticateAsync_Success_EmitsCanonicalZbClaims asserting
  zb:username, zb:displayname, ZbClaimTypes.Role per role, Identity.Name, and ldap_group preserved.
2026-06-02 06:10:48 -04:00
Joseph Doherty 05009d7370 feat(auth): cut MxGateway API keys over to ZB.MOM.WW.Auth.ApiKeys 0.1.2; keep constraint enforcement+gRPC+CLI on top (Task 1.3) 2026-06-02 02:08:38 -04:00
Joseph Doherty f4dc11bae4 fix(auth): MxGateway 1.2 review fixes — group-claim doc, dedup LdapOptions, 0.1.1 pin 2026-06-02 01:28:57 -04:00
Joseph Doherty c3b466e13d feat(auth): cut MxGateway dashboard LDAP over to ZB.MOM.WW.Auth.Ldap; roles via IGroupRoleMapper (Task 1.2/1.4) 2026-06-02 00:51:10 -04:00
Joseph Doherty 792e3f9445 feat(auth): add IGroupRoleMapper<string> seam (Task 1.1) 2026-06-02 00:31:00 -04:00
Joseph Doherty ae281d06bb build: add ZB.MOM.WW.Auth/Audit feed mapping
Maps ZB.MOM.WW.Auth, ZB.MOM.WW.Auth.*, ZB.MOM.WW.Audit to the gitea feed.
PackageReferences (inline Version=) added during Phase 1/2 adoption.
2026-06-02 00:17:10 -04:00
Joseph Doherty 3ca2799c90 fix: tighten MxGateway Ldap:Port to 1-65535; catch IOException in path validation
Defect 1: ValidateLdap used AddIfNotPositive for Port, accepting any value
> 0 including 70000. Replaced with builder.Port() from the shared
ZB.MOM.WW.Configuration library, which enforces the 1-65535 TCP range and
emits "MxGateway:Ldap:Port must be between 1 and 65535 (was {value})".

Defect 2: AddIfInvalidPath only caught ArgumentException, NotSupportedException,
and PathTooLongException from Path.GetFullPath. On macOS/Linux a path containing
an embedded null throws IOException, which escaped the catch block and caused
Validate() to throw instead of returning a failure. Added catch (IOException).

Tests: added Validate_Fails_WhenLdapPortIsZero, Validate_Fails_WhenLdapPortExceedsMaximum,
and Validate_Succeeds_WhenLdapEnabledWithValidPort to cover the new range boundary.
2026-06-01 22:45:16 -04:00
Joseph Doherty 459a88b3e7 refactor: adopt ZB.MOM.WW.Configuration in MxGateway (behaviour-preserving) 2026-06-01 18:22:21 -04:00
Joseph Doherty 437ab65fc1 build: add ZB.MOM.WW.Configuration feed mapping + version pin 2026-06-01 18:10:27 -04:00
Joseph Doherty 679562e5ed Merge feat/telemetry-followons: telemetry follow-ons for MxAccessGateway
Metric normalization: meter MxGateway.Server -> ZB.MOM.WW.MxGateway and the 3
duration histograms ms -> s (safe: never Prometheus-exported before). Config-driven
OTLP exporter opt-in (default Prometheus). Metrics.md synced; doc-review artifacts
gitignored.
2026-06-01 17:17:31 -04:00
Joseph Doherty dbf550da8b docs(mxgateway): sync Metrics.md to renamed meter + seconds histogram units 2026-06-01 16:48:46 -04:00
Joseph Doherty 3965a7741e feat(mxgateway): config-driven OTLP exporter opt-in (default Prometheus) 2026-06-01 16:44:40 -04:00
Joseph Doherty abb2cfb84b feat(mxgateway): normalize metrics — meter ZB.MOM.WW.MxGateway + histograms in seconds 2026-06-01 16:39:56 -04:00
Joseph Doherty 4e0d8ccfed chore(mxgateway): gitignore CommentChecker doc-review artifacts 2026-06-01 16:34:46 -04:00
Joseph Doherty a935aa8b7c Merge feat/adopt-zb-telemetry: adopt ZB.MOM.WW.Telemetry across MxAccessGateway
Full MEL->Serilog migration via AddZbSerilog; GatewayLogRedactor exposed through
the shared ILogRedactor seam; GatewayMetrics now exports via AddZbTelemetry + new
/metrics (meter name MxGateway.Server + ms histogram units unchanged; rename/unit
conversion deferred). Behaviour-preserving.
2026-06-01 16:05:41 -04:00
Joseph Doherty 9912389fa1 feat(mxgateway): export GatewayMetrics via AddZbTelemetry + /metrics (name/units unchanged) 2026-06-01 15:53:46 -04:00
Joseph Doherty f1129b969d feat(mxgateway): expose GatewayLogRedactor via shared ILogRedactor seam 2026-06-01 15:49:32 -04:00
Joseph Doherty c51b6f9ce4 feat(mxgateway): adopt AddZbSerilog — MEL→Serilog provider swap (behaviour-preserving) 2026-06-01 15:43:10 -04:00
Joseph Doherty e39972357b config(mxgateway): translate MEL Logging section to Serilog 2026-06-01 15:32:38 -04:00
Joseph Doherty 9ad17e2964 build(mxgateway): reference ZB.MOM.WW.Telemetry + Serilog packages 2026-06-01 15:29:43 -04:00
Joseph Doherty ef0a883a81 Merge feat/adopt-zb-health: ZB.MOM.WW.Health adoption + TLS auto-cert/lenient-client-trust feature 2026-06-01 14:09:24 -04:00
Joseph Doherty 62ba5e9487 feat: map canonical ZB health tiers; replace bypassing /health/live 2026-06-01 13:44:13 -04:00
Joseph Doherty 136614be94 feat: add AuthStoreHealthCheck readiness probe 2026-06-01 13:33:54 -04:00
Joseph Doherty a912bffad5 build: reference ZB.MOM.WW.Health from the Gitea feed 2026-06-01 13:29:39 -04:00
258 changed files with 4027 additions and 3838 deletions
+5
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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 |
+26 -20
View File
@@ -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) |
+26
View File
@@ -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,
@@ -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" />
&#9776; <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">&#9646;</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
@@ -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 = [];
@@ -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": "&lt;escaped&gt;"}</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