docs+ui: backfill XML doc comments and finish dashboard layout pass

Adds missing <summary>/<param> XML docs across 99 server, worker, and test
files so CommentChecker reports zero issues (TreatWarningsAsErrors needs the
analyzer clean). Bundles in WIP dashboard work: NavSection extraction,
MainLayout/site.css/js styling alignment, and DashboardOptions/Auth tweaks.
This commit is contained in:
Joseph Doherty
2026-05-27 14:20:10 -04:00
parent 382861c602
commit 615b487a77
110 changed files with 1473 additions and 192 deletions
@@ -683,8 +683,11 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private sealed class Subscriber(Channel<AlarmFeedMessage> channel, string prefix)
{
/// <summary>Gets the channel for publishing alarm messages to this subscriber.</summary>
public Channel<AlarmFeedMessage> Channel { get; } = channel;
/// <summary>Determines whether the alarm reference matches this subscriber's filter.</summary>
/// <param name="reference">The alarm reference to match.</param>
public bool Matches(string reference)
{
return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal);
@@ -8,6 +8,19 @@ public sealed class DashboardOptions
/// <summary>Gets whether anonymous localhost access to dashboard is allowed.</summary>
public bool AllowAnonymousLocalhost { get; init; } = true;
/// <summary>
/// When true (default), the dashboard auth cookie is restricted to HTTPS
/// requests via <see cref="Microsoft.AspNetCore.Http.CookieSecurePolicy.Always"/>.
/// Set to false for plain-HTTP dev deployments — the cookie then uses
/// <see cref="Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest"/>,
/// which still marks it Secure on any HTTPS request but allows it to
/// round-trip over HTTP. Browsers silently drop Secure cookies set over
/// plain HTTP from non-localhost hosts, so leaving this true breaks
/// dashboard login from a remote browser unless the dashboard is served
/// over HTTPS.
/// </summary>
public bool RequireHttpsCookie { get; init; } = true;
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
@@ -9,6 +9,7 @@ public sealed class GatewayOptions
/// </summary>
public AuthenticationOptions Authentication { get; init; } = new();
/// <summary>Gets LDAP configuration options.</summary>
public LdapOptions Ldap { get; init; } = new();
/// <summary>
@@ -2,25 +2,36 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
public sealed class LdapOptions
{
/// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary>
public bool Enabled { get; init; } = true;
/// <summary>Gets the LDAP server address.</summary>
public string Server { get; init; } = "localhost";
/// <summary>Gets the LDAP server port.</summary>
public int Port { get; init; } = 3893;
/// <summary>Gets a value indicating whether TLS is required for the connection.</summary>
public bool UseTls { get; init; }
/// <summary>Gets a value indicating whether insecure LDAP connections are allowed.</summary>
public bool AllowInsecureLdap { get; init; } = true;
/// <summary>Gets the LDAP search base distinguished name.</summary>
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
/// <summary>Gets the service account distinguished name.</summary>
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=lmxopcua,dc=local";
/// <summary>Gets the service account password.</summary>
public string ServiceAccountPassword { get; init; } = "serviceaccount123";
/// <summary>Gets the LDAP attribute name for user names.</summary>
public string UserNameAttribute { get; init; } = "cn";
/// <summary>Gets the LDAP attribute name for display names.</summary>
public string DisplayNameAttribute { get; init; } = "cn";
/// <summary>Gets the LDAP attribute name for group membership.</summary>
public string GroupAttribute { get; init; } = "memberOf";
}
@@ -12,5 +12,6 @@ public sealed class ProtocolOptions
/// </summary>
public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion;
/// <summary>Gets or sets the maximum gRPC message size in bytes.</summary>
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
}
@@ -17,8 +17,10 @@ public sealed class SessionOptions
/// </summary>
public int MaxPendingCommandsPerSession { get; init; } = 128;
/// <summary>Gets the default session lease duration in seconds.</summary>
public int DefaultLeaseSeconds { get; init; } = 1800;
/// <summary>Gets the interval for sweeping expired session leases in seconds.</summary>
public int LeaseSweepIntervalSeconds { get; init; } = 30;
/// <summary>
@@ -12,6 +12,7 @@
<body class="dashboard-body">
<Routes @rendermode="InteractiveServer" />
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="/js/nav-state.js"></script>
<script src="/_framework/blazor.web.js"></script>
</body>
</html>
@@ -1,83 +1,210 @@
@using System.Linq
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.JSInterop
@implements IDisposable
@inherits LayoutComponentBase
@inject NavigationManager Navigation
@inject IJSRuntime JS
<header class="app-bar">
<a class="brand" href="/"><span class="mark">&#9646;</span> MXAccess Gateway</a>
<span class="crumb">&rsaquo;</span>
<span class="crumb">gateway dashboard</span>
<span class="spacer"></span>
<AuthorizeView>
<Authorized Context="authState">
<span class="meta">@authState.User.Identity?.Name</span>
<span class="conn-pill" data-state="connected">
<span class="dot"></span><span>signed in</span>
</span>
</Authorized>
<NotAuthorized>
<span class="conn-pill" data-state="disconnected">
<span class="dot"></span><span>signed out</span>
</span>
</NotAuthorized>
</AuthorizeView>
</header>
<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">
@* Hamburger toggle: visible only on viewports <lg. Bootstrap collapse JS
lives in bootstrap.bundle.min.js (loaded in App.razor). *@
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
type="button"
data-bs-toggle="collapse"
data-bs-target="#sidebar-collapse"
aria-controls="sidebar-collapse"
aria-expanded="false"
aria-label="Toggle navigation">
&#9776;
</button>
@* Hamburger toggle: visible only on viewports <lg. Bootstrap collapse JS lives
in bootstrap.bundle.min.js (loaded in App.razor). The app-bar above stays
full-width; the hamburger only collapses the side rail. *@
<button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
type="button"
data-bs-toggle="collapse"
data-bs-target="#sidebar-collapse"
aria-controls="sidebar-collapse"
aria-expanded="false"
aria-label="Toggle navigation">
&#9776;
</button>
<div class="app-shell">
<div class="collapse d-lg-block" id="sidebar-collapse">
<nav class="side-rail">
<div class="rail-eyebrow">Overview</div>
<NavLink class="rail-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
<nav class="sidebar d-flex flex-column">
<a class="brand" href="/"><span class="mark">&#9646;</span> MXAccess Gateway</a>
<div class="rail-eyebrow">Runtime</div>
<NavLink class="rail-link" href="/sessions" Match="NavLinkMatch.Prefix">Sessions</NavLink>
<NavLink class="rail-link" href="/workers" Match="NavLinkMatch.Prefix">Workers</NavLink>
<NavLink class="rail-link" href="/events" Match="NavLinkMatch.Prefix">Events</NavLink>
<NavLink class="rail-link" href="/alarms" Match="NavLinkMatch.Prefix">Alarms</NavLink>
<div style="overflow-y:auto; flex:1 1 auto; min-height:0;">
<ul class="nav flex-column">
<li class="nav-item">
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Dashboard</NavLink>
</li>
<div class="rail-eyebrow">Galaxy</div>
<NavLink class="rail-link" href="/galaxy" Match="NavLinkMatch.Prefix">Repository</NavLink>
<NavLink class="rail-link" href="/browse" Match="NavLinkMatch.Prefix">Browse</NavLink>
<NavSection Title="Runtime"
Expanded="@_expanded.Contains("runtime")"
OnToggle="@(() => ToggleAsync("runtime"))">
<li class="nav-item">
<NavLink class="nav-link" href="/sessions" Match="NavLinkMatch.Prefix">Sessions</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/workers" Match="NavLinkMatch.Prefix">Workers</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/events" Match="NavLinkMatch.Prefix">Events</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="/alarms" Match="NavLinkMatch.Prefix">Alarms</NavLink>
</li>
</NavSection>
<div class="rail-eyebrow">Admin</div>
<NavLink class="rail-link" href="/apikeys" Match="NavLinkMatch.Prefix">API keys</NavLink>
<NavLink class="rail-link" href="/settings" Match="NavLinkMatch.Prefix">Settings</NavLink>
<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>
<div class="rail-foot">
<AuthorizeView>
<Authorized Context="authState">
<div class="rail-eyebrow">Session</div>
<div class="rail-user">@authState.User.Identity?.Name</div>
<div class="rail-roles">
@string.Join(", ", authState.User.Claims
.Where(c => c.Type == System.Security.Claims.ClaimTypes.Role)
.Select(c => c.Value))
</div>
<form method="post" action="/logout">
<AntiforgeryToken />
<button class="rail-btn" type="submit">Sign out</button>
</form>
</Authorized>
<NotAuthorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-btn" href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
<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">
<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;
}
}
@@ -0,0 +1,35 @@
@* 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; }
}
@@ -4,6 +4,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardApiKeyAuthorization
{
/// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The authenticated user principal.</param>
public bool CanManage(ClaimsPrincipal user)
{
if (user.Identity?.IsAuthenticated != true)
@@ -5,11 +5,18 @@ public sealed record DashboardApiKeyManagementResult(
string Message,
string? ApiKey)
{
/// <summary>Creates a successful result with an optional API key.</summary>
/// <param name="message">The success message.</param>
/// <param name="apiKey">The API key if generated or modified.</param>
/// <returns>A successful result record.</returns>
public static DashboardApiKeyManagementResult Success(string message, string? apiKey = null)
{
return new DashboardApiKeyManagementResult(true, message, apiKey);
}
/// <summary>Creates a failed result with an error message.</summary>
/// <param name="message">The error message.</param>
/// <returns>A failed result record.</returns>
public static DashboardApiKeyManagementResult Fail(string message)
{
return new DashboardApiKeyManagementResult(false, message, null);
@@ -14,11 +14,17 @@ public sealed class DashboardApiKeyManagementService(
{
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
/// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The authenticated user principal.</param>
public bool CanManage(ClaimsPrincipal user)
{
return authorization.CanManage(user);
}
/// <summary>Creates an API key asynchronously.</summary>
/// <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(
ClaimsPrincipal user,
DashboardApiKeyManagementRequest request,
@@ -67,6 +73,10 @@ public sealed class DashboardApiKeyManagementService(
}
}
/// <summary>Revokes an API key asynchronously.</summary>
/// <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(
ClaimsPrincipal user,
string keyId,
@@ -100,6 +110,10 @@ public sealed class DashboardApiKeyManagementService(
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
}
/// <summary>Rotates an API key secret asynchronously.</summary>
/// <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(
ClaimsPrincipal user,
string keyId,
@@ -143,6 +157,10 @@ public sealed class DashboardApiKeyManagementService(
}
}
/// <summary>Deletes a revoked API key asynchronously.</summary>
/// <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(
ClaimsPrincipal user,
string keyId,
@@ -35,5 +35,5 @@ public static class DashboardAuthenticationDefaults
public const string LdapGroupClaimType = "mxgateway:ldap_group";
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
public const string CookieName = "__Host-MxGatewayDashboard";
public const string CookieName = "MxGatewayDashboard";
}
@@ -117,6 +117,8 @@ public sealed class DashboardAuthenticator(
}
}
/// <summary>Escapes special characters in LDAP filter strings.</summary>
/// <param name="value">The string value to escape.</param>
internal static string EscapeLdapFilter(string value)
{
StringBuilder builder = new(value.Length);
@@ -141,6 +143,8 @@ public sealed class DashboardAuthenticator(
/// 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>
internal static IReadOnlyList<string> MapGroupsToRoles(
IEnumerable<string> groups,
IReadOnlyDictionary<string, string> groupToRole)
@@ -171,6 +175,8 @@ public sealed class DashboardAuthenticator(
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('=');
@@ -8,11 +8,14 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// </summary>
public sealed class DashboardAuthorizationRequirement(IReadOnlyList<string> requiredRoles) : IAuthorizationRequirement
{
/// <summary>Gets the list of roles required to satisfy this requirement.</summary>
public IReadOnlyList<string> RequiredRoles { get; } = requiredRoles;
/// <summary>Gets a requirement satisfied by any dashboard role (admin or viewer).</summary>
public static DashboardAuthorizationRequirement AnyDashboardRole { get; } =
new([DashboardRoles.Admin, DashboardRoles.Viewer]);
/// <summary>Gets a requirement satisfied only by the admin role.</summary>
public static DashboardAuthorizationRequirement AdminOnly { get; } =
new([DashboardRoles.Admin]);
}
@@ -4,6 +4,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public static class DashboardConnectionStringDisplay
{
/// <summary>Returns a sanitized Galaxy Repository connection string for display.</summary>
/// <param name="connectionString">The connection string to sanitize.</param>
public static string GalaxyRepositoryConnectionString(string connectionString)
{
try
@@ -191,11 +191,22 @@ public static class DashboardEndpointRouteBuilderExtensions
</section>
""";
return RenderPage("Dashboard Sign In", body);
return RenderPage("Dashboard Sign In", heading: null, body);
}
private static string RenderPage(string title, string body)
=> RenderPage(title, heading: title, body);
private static string RenderPage(string title, string? heading, string body)
{
string headingHtml = string.IsNullOrEmpty(heading)
? string.Empty
: $"""
<div class="dashboard-page-header">
<h1>{HtmlEncoder.Default.Encode(heading)}</h1>
</div>
""";
return $"""
<!doctype html>
<html lang="en">
@@ -212,9 +223,7 @@ public static class DashboardEndpointRouteBuilderExtensions
<span class="brand"><span class="mark">&#9646;</span> MXAccess Gateway</span>
</header>
<main class="page">
<div class="dashboard-page-header">
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
</div>
{headingHtml}
{body}
</main>
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
@@ -5,6 +5,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>Projects the precomputed Galaxy cache dashboard summary.</summary>
internal static class DashboardGalaxyProjector
{
/// <summary>Projects the cache entry to a dashboard Galaxy summary.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
{
return entry.DashboardSummary;
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -39,8 +41,10 @@ public static class DashboardServiceCollectionExtensions
{
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
cookieOptions.Cookie.HttpOnly = true;
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
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.LoginPath = "/login";
cookieOptions.LogoutPath = "/logout";
@@ -52,6 +56,14 @@ public static class DashboardServiceCollectionExtensions
DashboardAuthenticationDefaults.HubAuthenticationScheme,
_ => { });
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
{
cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie
? CookieSecurePolicy.Always
: CookieSecurePolicy.SameAsRequest;
});
services.AddAuthorization(authorization =>
{
authorization.AddPolicy(
@@ -4,11 +4,15 @@ public sealed record DashboardSessionAdminResult(
bool Succeeded,
string Message)
{
/// <summary>Creates a successful result with the given message.</summary>
/// <param name="message">The result message.</param>
public static DashboardSessionAdminResult Success(string message)
{
return new DashboardSessionAdminResult(true, message);
}
/// <summary>Creates a failed result with the given message.</summary>
/// <param name="message">The result message.</param>
public static DashboardSessionAdminResult Fail(string message)
{
return new DashboardSessionAdminResult(false, message);
@@ -35,8 +35,10 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
/// <param name="metrics">Gateway metrics collector.</param>
/// <param name="configurationProvider">Gateway configuration provider.</param>
/// <param name="galaxyHierarchyCache">Galaxy hierarchy cache.</param>
/// <param name="apiKeyAdminStore">API key administration store.</param>
/// <param name="options">Gateway configuration options.</param>
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
/// <param name="logger">Optional logger for the dashboard snapshot service.</param>
public DashboardSnapshotService(
ISessionRegistry sessionRegistry,
GatewayMetrics metrics,
@@ -14,6 +14,11 @@ public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<Authen
{
private readonly HubTokenService _tokens;
/// <summary>Initializes a new hub token authentication handler.</summary>
/// <param name="options">The options monitor for the authentication scheme.</param>
/// <param name="logger">The logger factory for logging authentication events.</param>
/// <param name="encoder">The URL encoder for encoding authentication values.</param>
/// <param name="tokens">The hub token service for validating tokens.</param>
public HubTokenAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
@@ -24,6 +29,7 @@ public sealed class HubTokenAuthenticationHandler : AuthenticationHandler<Authen
_tokens = tokens;
}
/// <inheritdoc />
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
string? token = ExtractToken();
@@ -30,13 +30,16 @@ public sealed class HubTokenService
private readonly ITimeLimitedDataProtector _protector;
/// <summary>Initializes a new instance of the HubTokenService with a data protection provider.</summary>
/// <param name="dataProtection">The data protection provider for token encryption.</param>
public HubTokenService(IDataProtectionProvider dataProtection)
{
ArgumentNullException.ThrowIfNull(dataProtection);
_protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector();
}
/// <summary>Issue 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>
public string Issue(ClaimsPrincipal user)
{
ArgumentNullException.ThrowIfNull(user);
@@ -47,7 +50,8 @@ public sealed class HubTokenService
return _protector.Protect(JsonSerializer.Serialize(payload), TokenLifetime);
}
/// <summary>Validate a token and return the equivalent <see cref="ClaimsPrincipal"/>; 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>
public ClaimsPrincipal? Validate(string? token)
{
if (string.IsNullOrEmpty(token))
@@ -17,6 +17,7 @@ public sealed class AlarmsHub : Hub
/// <summary>Method name used to push <c>AlarmFeedMessage</c> values to clients.</summary>
public const string AlarmMessage = "AlarmFeed";
/// <inheritdoc />
public override async Task OnConnectedAsync()
{
await Groups.AddToGroupAsync(Context.ConnectionId, AllAlarmsGroup).ConfigureAwait(false);
@@ -16,6 +16,7 @@ public sealed class AlarmsHubPublisher(
IHubContext<AlarmsHub> hubContext,
ILogger<AlarmsHubPublisher> logger) : BackgroundService
{
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Loop forever — when StreamAsync completes (monitor restart, etc.)
@@ -14,6 +14,9 @@ public sealed class DashboardEventBroadcaster(
IHubContext<EventsHub> hubContext,
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
{
/// <summary>Publishes an MX event to connected dashboard clients.</summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="mxEvent">The MX event to publish.</param>
public void Publish(string sessionId, MxEvent mxEvent)
{
if (string.IsNullOrEmpty(sessionId) || mxEvent is null)
@@ -16,6 +16,9 @@ public sealed class DashboardHubConnectionFactory(
HubTokenService tokens,
AuthenticationStateProvider authState)
{
/// <summary>Creates a new hub connection to the specified hub path.</summary>
/// <param name="hubPath">The relative hub path (e.g., "/hubs/snapshot").</param>
/// <returns>A configured hub connection with automatic reconnection and token authentication.</returns>
public HubConnection Create(string hubPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(hubPath);
@@ -15,6 +15,7 @@ public sealed class DashboardSnapshotHub(IDashboardSnapshotService snapshotServi
/// <summary>Method name used to push snapshot updates to clients.</summary>
public const string SnapshotMessage = "SnapshotUpdated";
/// <inheritdoc />
public override async Task OnConnectedAsync()
{
await Clients.Caller.SendAsync(SnapshotMessage, snapshotService.GetSnapshot()).ConfigureAwait(false);
@@ -26,6 +26,10 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
private readonly ILogger<DashboardSnapshotPublisher> _logger;
private readonly TimeSpan _reconnectDelay;
/// <summary>Initializes a new instance of the DashboardSnapshotPublisher class.</summary>
/// <param name="snapshotService">The snapshot service to subscribe to.</param>
/// <param name="hubContext">The SignalR hub context for broadcasting.</param>
/// <param name="logger">The logger instance.</param>
public DashboardSnapshotPublisher(
IDashboardSnapshotService snapshotService,
IHubContext<DashboardSnapshotHub> hubContext,
@@ -34,9 +38,12 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
{
}
// Internal hook for the Server-042 regression test: tests inject a
// very short reconnect delay so the assertion doesn't wait the full
// 5s. Production wiring always uses the 5s default via the public ctor.
/// <summary>Initializes a new instance of the DashboardSnapshotPublisher class with custom reconnect delay.</summary>
/// <remarks>Internal hook for testing: tests inject a very short reconnect delay so assertions don't wait full 5s.</remarks>
/// <param name="snapshotService">The snapshot service to subscribe to.</param>
/// <param name="hubContext">The SignalR hub context for broadcasting.</param>
/// <param name="logger">The logger instance.</param>
/// <param name="reconnectDelay">The delay before reconnecting after a subscription failure.</param>
internal DashboardSnapshotPublisher(
IDashboardSnapshotService snapshotService,
IHubContext<DashboardSnapshotHub> hubContext,
@@ -49,6 +56,7 @@ public sealed class DashboardSnapshotPublisher : BackgroundService
_reconnectDelay = reconnectDelay;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Loop forever — when WatchSnapshotsAsync completes or throws, reopen
@@ -21,6 +21,9 @@ public sealed class EventsHub : Hub
/// <summary>Method name used to push individual <c>MxEvent</c> values to clients.</summary>
public const string EventMessage = "MxEvent";
/// <summary>Computes the SignalR group name for a given session id.</summary>
/// <param name="sessionId">The session id.</param>
/// <returns>The group name for the session.</returns>
public static string GroupName(string sessionId) => $"session:{sessionId}";
/// <summary>
@@ -56,6 +59,9 @@ public sealed class EventsHub : Hub
return Groups.AddToGroupAsync(Context.ConnectionId, GroupName(sessionId));
}
/// <summary>Unsubscribes the calling SignalR connection from the per-session events group.</summary>
/// <param name="sessionId">Session id to unsubscribe the caller from.</param>
/// <returns>A task representing the unsubscription operation.</returns>
public Task UnsubscribeSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
@@ -10,5 +10,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// </summary>
public interface IDashboardEventBroadcaster
{
/// <summary>Publishes an MxEvent to all dashboard clients subscribed to the session.</summary>
/// <param name="sessionId">The session identifier.</param>
/// <param name="mxEvent">The MxEvent to publish.</param>
void Publish(string sessionId, MxEvent mxEvent);
}
@@ -4,23 +4,46 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public interface IDashboardApiKeyManagementService
{
/// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The user principal.</param>
/// <returns>True if the user can manage keys; otherwise false.</returns>
bool CanManage(ClaimsPrincipal user);
/// <summary>Creates a new API key.</summary>
/// <param name="user">The user principal.</param>
/// <param name="request">The key creation request details.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The creation result.</returns>
Task<DashboardApiKeyManagementResult> CreateAsync(
ClaimsPrincipal user,
DashboardApiKeyManagementRequest request,
CancellationToken cancellationToken);
/// <summary>Revokes an existing API key.</summary>
/// <param name="user">The user principal.</param>
/// <param name="keyId">The key identifier to revoke.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The revocation result.</returns>
Task<DashboardApiKeyManagementResult> RevokeAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken);
/// <summary>Rotates an existing API key.</summary>
/// <param name="user">The user principal.</param>
/// <param name="keyId">The key identifier to rotate.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The rotation result.</returns>
Task<DashboardApiKeyManagementResult> RotateAsync(
ClaimsPrincipal user,
string keyId,
CancellationToken cancellationToken);
/// <summary>Deletes an API key.</summary>
/// <param name="user">The user principal.</param>
/// <param name="keyId">The key identifier to delete.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The deletion result.</returns>
Task<DashboardApiKeyManagementResult> DeleteAsync(
ClaimsPrincipal user,
string keyId,
@@ -6,9 +6,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public interface IDashboardAuthenticator
{
/// <summary>
/// Authenticates the dashboard session with an API key.
/// Authenticates the dashboard session with credentials.
/// </summary>
/// <param name="apiKey">The API key to authenticate.</param>
/// <param name="username">Username to authenticate.</param>
/// <param name="password">Password to authenticate.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<DashboardAuthenticationResult> AuthenticateAsync(
string? username,
@@ -43,6 +43,9 @@ public static class GalaxyGlobMatcher
/// </summary>
internal static int CurrentCacheSize => RegexCache.Count;
/// <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="glob">The glob pattern with * and ? wildcards.</param>
public static bool IsMatch(string value, string glob)
{
if (string.IsNullOrWhiteSpace(glob))
@@ -14,17 +14,24 @@ public sealed class GalaxyHierarchyIndex
TagsByAddress = tagsByAddress;
}
/// <summary>Gets an empty Galaxy hierarchy index.</summary>
public static GalaxyHierarchyIndex Empty { get; } = new(
Array.Empty<GalaxyObjectView>(),
new Dictionary<int, GalaxyObjectView>(),
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase));
/// <summary>Gets the object views.</summary>
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
/// <summary>Gets the object views indexed by GUID.</summary>
public IReadOnlyDictionary<int, GalaxyObjectView> ObjectViewsById { get; }
/// <summary>Gets tags indexed by address.</summary>
public IReadOnlyDictionary<string, GalaxyTagLookup> TagsByAddress { get; }
/// <summary>Builds a Galaxy hierarchy index from the given objects.</summary>
/// <param name="objects">The Galaxy objects to index.</param>
/// <returns>A new Galaxy hierarchy index.</returns>
public static GalaxyHierarchyIndex Build(IReadOnlyList<GalaxyObject> objects)
{
if (objects.Count == 0)
@@ -21,6 +21,10 @@ public static class GalaxyHierarchyProjector
/// </summary>
private static readonly ConditionalWeakTable<GalaxyHierarchyCacheEntry, ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>> FilteredViewCache = new();
/// <summary>Projects a discovery request against a cache entry and returns all matching objects.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="request">The discovery hierarchy request.</param>
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
public static GalaxyHierarchyQueryResult Project(
GalaxyHierarchyCacheEntry entry,
DiscoverHierarchyRequest request,
@@ -34,6 +38,12 @@ public static class GalaxyHierarchyProjector
pageSize: int.MaxValue);
}
/// <summary>Projects a discovery request with paging against a cache entry and returns a page of matching objects.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="request">The discovery hierarchy request.</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="pageSize">The maximum number of results to return.</param>
public static GalaxyHierarchyQueryResult Project(
GalaxyHierarchyCacheEntry entry,
DiscoverHierarchyRequest request,
@@ -118,6 +128,9 @@ public static class GalaxyHierarchyProjector
(Views: views, Root: root, MaxDepth: maxDepth, BrowseSubtreeGlobs: browseSubtreeGlobs, Request: request));
}
/// <summary>Finds an object in the hierarchy by its tag address.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="tagAddress">The tag address to search for.</param>
public static GalaxyObject? FindObjectForTag(
GalaxyHierarchyCacheEntry entry,
string tagAddress)
@@ -132,6 +145,9 @@ public static class GalaxyHierarchyProjector
: null;
}
/// <summary>Finds an attribute in the hierarchy by its tag address.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="tagAddress">The tag address to search for.</param>
public static GalaxyAttribute? FindAttributeForTag(
GalaxyHierarchyCacheEntry entry,
string tagAddress)
@@ -146,6 +162,9 @@ public static class GalaxyHierarchyProjector
: null;
}
/// <summary>Gets the contained path for an object by its gobject ID.</summary>
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
/// <param name="gobjectId">The Galaxy object ID.</param>
public static string GetContainedPath(
GalaxyHierarchyCacheEntry entry,
int gobjectId)
@@ -260,6 +279,9 @@ public static class GalaxyHierarchyProjector
return clone;
}
/// <summary>Computes a stable filter signature for memoization purposes.</summary>
/// <param name="request">The discovery hierarchy request.</param>
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
public static string ComputeFilterSignature(
DiscoverHierarchyRequest request,
IReadOnlyList<string>? browseSubtreeGlobs)
@@ -690,9 +690,13 @@ public sealed class MxAccessGatewayService(
bool HasAllowedItems)
{
/// <summary>Builds a reply containing only the denied entries (used when no items survived filtering).</summary>
/// <param name="request">The original command request.</param>
/// <returns>A reply with denied results only.</returns>
public abstract MxCommandReply CreateDeniedReply(MxCommandRequest request);
/// <summary>Splices denied entries back into the worker's allowed-only reply in original-index order.</summary>
/// <param name="reply">The worker's reply containing only allowed results.</param>
/// <returns>A merged reply in the original order.</returns>
public abstract MxCommandReply MergeDeniedInto(MxCommandReply reply);
}
@@ -703,6 +707,7 @@ public sealed class MxAccessGatewayService(
bool HasAllowedItems)
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
{
/// <inheritdoc />
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
{
MxCommandReply reply = new()
@@ -716,6 +721,7 @@ public sealed class MxAccessGatewayService(
return reply;
}
/// <inheritdoc />
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
{
BulkSubscribeReply allowed = GetPayload(reply) ?? new BulkSubscribeReply();
@@ -774,6 +780,7 @@ public sealed class MxAccessGatewayService(
bool HasAllowedItems)
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
{
/// <inheritdoc />
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
{
MxCommandReply reply = new()
@@ -787,6 +794,7 @@ public sealed class MxAccessGatewayService(
return reply;
}
/// <inheritdoc />
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
{
BulkWriteReply allowed = GetPayload(reply) ?? new BulkWriteReply();
@@ -849,6 +857,7 @@ public sealed class MxAccessGatewayService(
bool HasAllowedItems)
: BulkConstraintPlan(Command, OriginalCount, HasAllowedItems)
{
/// <inheritdoc />
public override MxCommandReply CreateDeniedReply(MxCommandRequest request)
{
MxCommandReply reply = new()
@@ -862,6 +871,7 @@ public sealed class MxAccessGatewayService(
return reply;
}
/// <inheritdoc />
public override MxCommandReply MergeDeniedInto(MxCommandReply reply)
{
BulkReadReply allowed = reply.ReadBulk ?? new BulkReadReply();
@@ -10,12 +10,16 @@ public static class ApiKeyConstraintSerializer
WriteIndented = false,
};
/// <summary>Serializes API key constraints to JSON, or returns null if the constraints are empty.</summary>
/// <param name="constraints">The constraints to serialize.</param>
public static string? Serialize(ApiKeyConstraints constraints)
{
ArgumentNullException.ThrowIfNull(constraints);
return constraints.IsEmpty ? null : JsonSerializer.Serialize(constraints, JsonOptions);
}
/// <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>
public static ApiKeyConstraints Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json))
@@ -10,6 +10,7 @@ public sealed record ApiKeyConstraints(
bool ReadAlarmOnly,
bool ReadHistorizedOnly)
{
/// <summary>Gets an empty constraints instance with no restrictions.</summary>
public static ApiKeyConstraints Empty { get; } = new(
ReadSubtrees: Array.Empty<string>(),
WriteSubtrees: Array.Empty<string>(),
@@ -20,6 +21,7 @@ public sealed record ApiKeyConstraints(
ReadAlarmOnly: false,
ReadHistorizedOnly: false);
/// <summary>Gets a value indicating whether the constraints are empty (no restrictions).</summary>
public bool IsEmpty =>
ReadSubtrees.Count == 0
&& WriteSubtrees.Count == 0
@@ -30,12 +32,14 @@ public sealed record ApiKeyConstraints(
&& !ReadAlarmOnly
&& !ReadHistorizedOnly;
/// <summary>Gets a value indicating whether any read constraints are defined.</summary>
public bool HasReadConstraints =>
ReadSubtrees.Count > 0
|| ReadTagGlobs.Count > 0
|| ReadAlarmOnly
|| ReadHistorizedOnly;
/// <summary>Gets a value indicating whether any write constraints are defined.</summary>
public bool HasWriteConstraints =>
WriteSubtrees.Count > 0
|| WriteTagGlobs.Count > 0
@@ -7,5 +7,6 @@ public sealed record ApiKeyIdentity(
IReadOnlySet<string> Scopes,
ApiKeyConstraints? Constraints = null)
{
/// <summary>Gets the effective API key constraints (defaults to Empty if null).</summary>
public ApiKeyConstraints EffectiveConstraints => Constraints ?? ApiKeyConstraints.Empty;
}
@@ -48,6 +48,8 @@ public sealed class AuthSqliteConnectionFactory(IOptions<GatewayOptions> options
/// non-zero busy timeout so concurrent readers and writers degrade gracefully
/// rather than surfacing <c>SQLITE_BUSY</c> as a hard failure.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An opened and configured SQLite connection.</returns>
public async Task<SqliteConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
SqliteConnection connection = CreateConnection();
@@ -9,6 +9,10 @@ public sealed class ConstraintEnforcer(
IGalaxyHierarchyCache cache,
IApiKeyAuditStore auditStore) : IConstraintEnforcer
{
/// <summary>Checks read constraints on a tag address.</summary>
/// <param name="identity">The API key identity to check constraints for.</param>
/// <param name="tagAddress">Tag address to validate.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
@@ -23,6 +27,12 @@ public sealed class ConstraintEnforcer(
return Task.FromResult(CheckReadTarget(constraints, tagAddress));
}
/// <summary>Checks read constraints on a server and item handle.</summary>
/// <param name="identity">The API key identity to check constraints for.</param>
/// <param name="session">The gateway session containing handle registrations.</param>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -44,6 +54,12 @@ public sealed class ConstraintEnforcer(
return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress));
}
/// <summary>Checks write constraints on a server and item handle.</summary>
/// <param name="identity">The API key identity to check constraints for.</param>
/// <param name="session">The gateway session containing handle registrations.</param>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -92,6 +108,12 @@ public sealed class ConstraintEnforcer(
return Task.FromResult<ConstraintFailure?>(null);
}
/// <summary>Records a constraint denial audit entry.</summary>
/// <param name="identity">The API key identity that was denied.</param>
/// <param name="commandKind">The command type (e.g., read, write).</param>
/// <param name="target">The target being accessed (tag address or handle).</param>
/// <param name="failure">The constraint failure details.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task RecordDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
@@ -5,11 +5,21 @@ namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
public interface IConstraintEnforcer
{
/// <summary>Checks whether a read constraint is satisfied for a tag address.</summary>
/// <param name="identity">The API key identity.</param>
/// <param name="tagAddress">Tag address to check.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
Task<ConstraintFailure?> CheckReadTagAsync(
ApiKeyIdentity? identity,
string tagAddress,
CancellationToken cancellationToken);
/// <summary>Checks whether a read constraint is satisfied for an item handle.</summary>
/// <param name="identity">The API key identity.</param>
/// <param name="session">The gateway session.</param>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
Task<ConstraintFailure?> CheckReadHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -17,6 +27,12 @@ public interface IConstraintEnforcer
int itemHandle,
CancellationToken cancellationToken);
/// <summary>Checks whether a write constraint is satisfied for an item handle.</summary>
/// <param name="identity">The API key identity.</param>
/// <param name="session">The gateway session.</param>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
Task<ConstraintFailure?> CheckWriteHandleAsync(
ApiKeyIdentity? identity,
GatewaySession session,
@@ -24,6 +40,12 @@ public interface IConstraintEnforcer
int itemHandle,
CancellationToken cancellationToken);
/// <summary>Records a constraint denial for audit and metrics.</summary>
/// <param name="identity">The API key identity.</param>
/// <param name="commandKind">The kind of command denied.</param>
/// <param name="target">The target of the denied command.</param>
/// <param name="failure">The constraint failure details.</param>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
Task RecordDenialAsync(
ApiKeyIdentity? identity,
string commandKind,
@@ -58,6 +58,21 @@ public sealed class GatewaySession
{
}
/// <summary>
/// Initializes a gateway session with session metadata, timeout configuration, and custom lease duration.
/// </summary>
/// <param name="sessionId">Identifier of the session.</param>
/// <param name="backendName">Name of the backend MXAccess proxy server.</param>
/// <param name="pipeName">Name of the named pipe for gateway-worker IPC.</param>
/// <param name="nonce">Security nonce for worker validation.</param>
/// <param name="clientIdentity">Client identity from the authentication context.</param>
/// <param name="clientSessionName">Client-supplied session name.</param>
/// <param name="clientCorrelationId">Client-supplied correlation identifier.</param>
/// <param name="commandTimeout">Timeout for command invocation.</param>
/// <param name="startupTimeout">Timeout for worker process startup.</param>
/// <param name="shutdownTimeout">Timeout for worker process shutdown.</param>
/// <param name="leaseDuration">Duration of the session lease.</param>
/// <param name="openedAt">Timestamp when the session opened.</param>
public GatewaySession(
string sessionId,
string backendName,
@@ -158,6 +173,7 @@ public sealed class GatewaySession
/// </summary>
public TimeSpan ShutdownTimeout { get; }
/// <summary>Gets the lease duration for the session.</summary>
public TimeSpan LeaseDuration { get; }
/// <summary>
@@ -406,6 +422,10 @@ public sealed class GatewaySession
return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false);
}
/// <summary>Gets the item registration for a server and item handle pair.</summary>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="itemHandle">The MXAccess item handle.</param>
/// <param name="registration">The item registration if found.</param>
public bool TryGetItemRegistration(
int serverHandle,
int itemHandle,
@@ -417,6 +437,9 @@ public sealed class GatewaySession
}
}
/// <summary>Tracks item registrations from a command reply.</summary>
/// <param name="command">The executed command.</param>
/// <param name="reply">The command reply.</param>
public void TrackCommandReply(
MxCommand command,
MxCommandReply reply)
@@ -608,9 +631,10 @@ public sealed class GatewaySession
cancellationToken);
}
/// <summary>
/// Executes a bulk Write command for the specified server and per-item entries.
/// </summary>
/// <summary>Executes a bulk Write command for the specified server and per-item entries.</summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<BulkWriteResult>> WriteBulkAsync(
int serverHandle,
IReadOnlyList<WriteBulkEntry> entries,
@@ -631,6 +655,9 @@ public sealed class GatewaySession
}
/// <summary>Executes a bulk Write2 (timestamped) command.</summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<BulkWriteResult>> Write2BulkAsync(
int serverHandle,
IReadOnlyList<Write2BulkEntry> entries,
@@ -651,6 +678,9 @@ public sealed class GatewaySession
}
/// <summary>Executes a bulk WriteSecured command.</summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<BulkWriteResult>> WriteSecuredBulkAsync(
int serverHandle,
IReadOnlyList<WriteSecuredBulkEntry> entries,
@@ -671,6 +701,9 @@ public sealed class GatewaySession
}
/// <summary>Executes a bulk WriteSecured2 command.</summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="entries">Write entries to execute.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<BulkWriteResult>> WriteSecured2BulkAsync(
int serverHandle,
IReadOnlyList<WriteSecured2BulkEntry> entries,
@@ -694,6 +727,10 @@ public sealed class GatewaySession
/// Executes a bulk Read command — see <c>ReadBulkCommand</c>'s doc
/// comment in the .proto for the cached-vs-snapshot semantics.
/// </summary>
/// <param name="serverHandle">Server handle returned by the worker.</param>
/// <param name="tagAddresses">Tag addresses to read.</param>
/// <param name="timeout">Timeout for the read operation.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<IReadOnlyList<BulkReadResult>> ReadBulkAsync(
int serverHandle,
IReadOnlyList<string> tagAddresses,
@@ -13,6 +13,7 @@ public sealed class SessionLeaseMonitorHostedService(
{
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.Sessions.LeaseSweepIntervalSeconds));
@@ -15,34 +15,23 @@ body.dashboard-body { min-height: 100vh; }
.app-bar .brand { color: var(--ink); }
.app-bar .brand:hover { text-decoration: none; }
/* ── App shell ───────────────────────────────────────────────────────────────
Two-column layout: fixed-width side rail (220px) + flexible main column.
Becomes single-column under <lg viewports — see media queries below. */
.app-shell {
display: flex;
align-items: stretch;
min-height: calc(100vh - 3.3rem);
}
.app-shell > .page { flex: 1; min-width: 0; }
/* ── Side rail ───────────────────────────────────────────────────────────────
Left-rail navigation with eyebrow section headings, a footer session
block, and active-route accent. */
.side-rail {
width: 220px;
flex: 0 0 220px;
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 1rem 0.7rem;
/* ── Sidebar ─────────────────────────────────────────────────────────────────
Left-rail navigation. Pattern lifted from ScadaLink CentralUI: a fixed-width
sidebar that hosts the brand at the top, a scrollable nav region with
collapsible NavSections in the middle, and a sign-in/out footer at the
bottom. The sidebar is wrapped in a Bootstrap .collapse so a hamburger
button can show/hide it on <lg viewports. */
.sidebar {
min-width: 220px;
max-width: 220px;
height: 100vh;
background: var(--card);
border-right: 1px solid var(--rule-strong);
}
/* Sidebar collapse wrapper: on lg+ it stays sticky to the viewport so the rail
remains visible when main content scrolls past 100vh (matches master). On
<lg viewports the Bootstrap .collapse class drives show/hide and the rail
spans full width — see the max-width media query below. */
/* Pin the sidebar to the viewport on lg+ so it stays visible when the main
content scrolls past 100vh. The wrapper is the flex child of MainLayout;
align-self prevents the flex row from stretching it. */
@media (min-width: 992px) {
#sidebar-collapse {
position: sticky;
@@ -53,82 +42,73 @@ body.dashboard-body { min-height: 100vh; }
}
}
/* When collapsed under <lg viewports the Bootstrap collapse container removes
the fixed width; restore full width on mobile. */
@media (max-width: 991.98px) {
.app-shell { flex-direction: column; }
#sidebar-collapse { width: 100%; }
.side-rail {
width: 100%;
flex: 0 0 auto;
.sidebar {
min-width: 100%;
max-width: 100%;
height: auto;
border-right: none;
border-bottom: 1px solid var(--rule-strong);
}
}
.rail-eyebrow {
font-size: 0.68rem;
.sidebar .brand {
display: block;
color: var(--ink);
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 0.02em;
padding: 1rem;
border-bottom: 1px solid var(--rule);
text-decoration: none;
}
.sidebar .brand:hover { text-decoration: none; }
.sidebar .brand .mark { color: var(--accent); }
.sidebar .nav-link {
color: var(--ink-soft);
padding: 0.4rem 1rem;
font-size: 0.9rem;
}
.sidebar .nav-link:hover {
color: var(--ink);
background-color: var(--paper);
text-decoration: none;
}
.sidebar .nav-link.active {
color: var(--accent-deep);
background-color: var(--paper);
font-weight: 600;
/* Left accent so active state isn't carried by colour alone. */
border-left: 3px solid var(--accent);
padding-left: calc(1rem - 3px);
}
/* Collapsible section header — a full-width button styled as an uppercase
eyebrow with a leading expand/collapse chevron. */
.sidebar .nav-section-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
background: none;
border: 0;
cursor: pointer;
text-align: left;
color: var(--ink-faint);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
padding: 0.4rem 0.6rem 0.2rem;
padding: 0.75rem 1rem 0.25rem;
margin-top: 0.5rem;
}
.rail-link {
display: block;
padding: 0.4rem 0.6rem;
border-radius: 4px;
border-left: 2px solid transparent;
font-size: 0.86rem;
color: var(--ink-soft);
text-decoration: none;
}
.rail-link:hover {
background: #f3f6fd;
color: var(--ink);
text-decoration: none;
}
.rail-link.active {
background: #eef2fc;
border-left-color: var(--accent);
color: var(--accent-deep);
font-weight: 600;
}
.rail-foot {
margin-top: auto;
padding-top: 0.6rem;
border-top: 1px solid var(--rule);
}
.rail-user {
padding: 0 0.6rem;
font-weight: 600;
font-size: 0.88rem;
color: var(--ink);
}
.rail-roles {
padding: 0.1rem 0.6rem 0.5rem;
font-family: var(--mono);
font-size: 0.72rem;
color: var(--ink-faint);
}
.rail-btn {
.sidebar .nav-section-toggle:hover { color: var(--ink); }
.sidebar .nav-section-toggle .chevron {
display: inline-block;
margin: 0 0.6rem;
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-soft);
background: var(--card);
border: 1px solid var(--rule-strong);
border-radius: 4px;
cursor: pointer;
text-decoration: none;
}
.rail-btn:hover {
border-color: var(--accent);
color: var(--accent);
text-decoration: none;
width: 0.8rem;
font-size: 0.8rem;
line-height: 1;
}
/* ── Page header ─────────────────────────────────────────────────────────────
@@ -0,0 +1,19 @@
// Sidebar nav collapse state — persisted in the `mxgateway_nav` cookie so it
// survives full page reloads and reconnects. Invoked from MainLayout.razor via
// JS interop (window.navState.get / .set). Pattern lifted from ScadaLink
// CentralUI's wwwroot/js/nav-state.js.
window.navState = {
// Returns the raw cookie value (comma-separated expanded section ids), or
// an empty string when the cookie is absent.
get: function () {
const match = document.cookie.match(/(?:^|;\s*)mxgateway_nav=([^;]*)/);
return match ? decodeURIComponent(match[1]) : "";
},
// Writes the cookie with a one-year lifetime. SameSite=Lax; not HttpOnly
// (JS must write it) and not sensitive.
set: function (value) {
const oneYearSeconds = 60 * 60 * 24 * 365;
document.cookie = "mxgateway_nav=" + encodeURIComponent(value) +
";path=/;max-age=" + oneYearSeconds + ";samesite=lax";
}
};