Compare commits

...

5 Commits

Author SHA1 Message Date
Joseph Doherty 2e4ba11a9f Merge remote-tracking branch 'origin/main' into agent-1/issue-17-implement-dashboard-authentication 2026-04-26 18:15:38 -04:00
Joseph Doherty ff86b3f0b0 Implement dashboard authentication 2026-04-26 18:15:22 -04:00
dohertj2 653f17c669 Merge pull request #75 from agent-2/issue-26-implement-register-and-unregister
Issue #26: implement Register and Unregister
2026-04-26 18:13:41 -04:00
Joseph Doherty 556c3bfa83 Implement worker register and unregister 2026-04-26 18:08:45 -04:00
dohertj2 9b3637257c Merge pull request #74 from agent-3/issue-14-implement-event-streaming-and-backpressure
Issue #14: implement event streaming and backpressure
2026-04-26 18:03:32 -04:00
26 changed files with 1288 additions and 9 deletions
+13 -7
View File
@@ -257,19 +257,18 @@ Do not show API key secrets or pepper values.
## Authentication And Authorization
Dashboard access should use the same API-key authentication model as gRPC where
Dashboard access uses the same API-key authentication model as gRPC where
practical.
Recommended v1 behavior:
Implemented v1 behavior:
- dashboard disabled by default unless configured,
- when enabled, require API key auth,
- require `admin` scope for dashboard access,
- accept API key through a secure cookie established by a simple login form, or
through reverse-proxy/header configuration for local deployments,
- do not put API keys in query strings.
- accept API key through a secure cookie established by a simple login form,
- do not put API keys in query strings,
- validate anti-forgery tokens for login and logout posts.
Simplest implementation path:
The implementation path is:
1. Add `/dashboard/login`.
2. User submits API key over HTTPS.
@@ -281,6 +280,13 @@ Simplest implementation path:
For local development, allow an explicit `Dashboard:AllowAnonymousLocalhost`
option. It must default to false.
`DashboardAuthenticator` keeps API-key validation outside UI components. It
formats the submitted key as a bearer authorization header for
`IApiKeyVerifier`, rejects non-admin keys when `Dashboard:RequireAdminScope` is
enabled, and creates the dashboard cookie principal without storing raw API key
material. `DashboardAuthorizationHandler` enforces the cookie, admin-scope, and
explicit loopback bypass decisions for all protected dashboard routes.
## Configuration
Suggested configuration:
+10
View File
@@ -663,6 +663,16 @@ server-streaming calls and stores the authenticated `ApiKeyIdentity` in
`Authentication:Mode` set to `Disabled` bypasses API-key verification for local
development only.
Dashboard authentication reuses the API-key verifier and scope model. The
dashboard login endpoint accepts the key in a form post, checks `admin` scope
when `Dashboard:RequireAdminScope` is enabled, and signs in with the
`MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only, secure, strict
SameSite, and scoped with the `__Host-MxGatewayDashboard` name. Logout clears
that cookie. Login and logout posts use anti-forgery validation, and dashboard
API keys are not accepted in query strings. `Dashboard:AllowAnonymousLocalhost`
allows only loopback requests to bypass the dashboard cookie requirement and
defaults to `false`.
Recommended scopes:
- `session:open`
+2 -1
View File
@@ -189,6 +189,8 @@ Tests:
Labels: `area:worker`, `type:feature`, `priority:p0`
Status: implemented.
Deliverables:
- `Register`,
@@ -447,4 +449,3 @@ Acceptance criteria:
- each public method has planned parity fixture or documented gap,
- gateway results preserve HRESULT/status/value/event shape.
+21 -1
View File
@@ -294,7 +294,10 @@ creates `LMXProxyServerClass` through `MxAccessComObjectFactory` on the STA,
attaches `MxAccessBaseEventSink`, and returns `WorkerReady` only after those
steps succeed. `MxAccessSession` keeps the raw COM object private, records the
STA managed thread id that created it, detaches the base event sink during
disposal, and releases the COM reference on the STA.
disposal, and releases the COM reference on the STA. After creation,
`MxAccessStaSession` owns a `StaCommandDispatcher` backed by
`MxAccessCommandExecutor`; `DispatchAsync` queues contract commands back to the
same STA instead of exposing the COM object to callers.
Creation rules:
@@ -414,6 +417,21 @@ Diagnostics:
Implement method-specific dispatch instead of a generic string method invoker.
Parity tests need stable command-specific request and reply shapes.
`MxAccessCommandExecutor` implements the first command pair:
- `Register` calls `LMXProxyServerClass.Register` with the requested client
name and preserves the returned server handle in both `ReturnValue` and
`RegisterReply.ServerHandle`.
- `Unregister` calls `LMXProxyServerClass.Unregister` with the requested server
handle. The reply has no method-specific payload because the public MXAccess
method returns `void`.
Both commands set `Hresult` to `0` only after the COM call returns normally.
COM exceptions flow through `StaCommandDispatcher`, which captures the thrown
HRESULT and converts the reply to `ProtocolStatusCode.MxaccessFailure`.
`MxAccessStaSession.GetRegisteredServerHandlesAsync` returns an STA-read
snapshot of tracked server handles for diagnostics and future cleanup logic.
## Handle Registry
The worker should track MXAccess state for diagnostics and cleanup, while still
@@ -434,6 +452,8 @@ Rules:
- Do not invent handles.
- Do not rewrite handles returned by MXAccess.
- Record server handles only after `Register` succeeds.
- Remove server handles only after `Unregister` succeeds.
- Preserve invalid-handle behavior from MXAccess.
- Preserve cross-server handle behavior from MXAccess.
- Use registry state for cleanup and diagnostics, not semantic correction.
+7
View File
@@ -109,6 +109,13 @@ histograms through .NET `Meter` and a snapshot API that dashboard services can
project without binding to a metrics exporter.
`DashboardSnapshotService` projects sessions, workers, metrics, faults, and
effective configuration into immutable DTOs for read-only dashboard rendering.
Dashboard routes use the same API-key verifier as gRPC. `/dashboard/login`
accepts the API key in a form body, validates the configured `admin` scope,
and issues an HTTP-only secure cookie for subsequent dashboard requests.
`/dashboard/logout` clears that cookie. Login and logout posts validate
anti-forgery tokens, and API keys are never accepted through query strings.
`Dashboard:AllowAnonymousLocalhost` can bypass the cookie requirement for
loopback requests only when explicitly enabled.
### Worker Process
@@ -0,0 +1,10 @@
namespace MxGateway.Server.Dashboard;
public static class DashboardAuthenticationDefaults
{
public const string AuthenticationScheme = "MxGateway.Dashboard";
public const string AuthorizationPolicy = "MxGateway.Dashboard";
public const string ScopeClaimType = "scope";
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
public const string CookieName = "__Host-MxGatewayDashboard";
}
@@ -0,0 +1,19 @@
using System.Security.Claims;
namespace MxGateway.Server.Dashboard;
public sealed record DashboardAuthenticationResult(
bool Succeeded,
ClaimsPrincipal? Principal,
string? FailureMessage)
{
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
{
return new DashboardAuthenticationResult(true, principal, null);
}
public static DashboardAuthenticationResult Fail(string failureMessage)
{
return new DashboardAuthenticationResult(false, null, failureMessage);
}
}
@@ -0,0 +1,81 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
namespace MxGateway.Server.Dashboard;
public sealed class DashboardAuthenticator(
IApiKeyVerifier apiKeyVerifier,
IOptions<GatewayOptions> options) : IDashboardAuthenticator
{
private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access.";
public async Task<DashboardAuthenticationResult> AuthenticateAsync(
string? apiKey,
CancellationToken cancellationToken)
{
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
{
return DashboardAuthenticationResult.Success(CreatePrincipal(new ApiKeyIdentity(
KeyId: "authentication-disabled",
KeyPrefix: "authentication-disabled",
DisplayName: "Authentication Disabled",
Scopes: new HashSet<string>([GatewayScopes.Admin], StringComparer.Ordinal))));
}
if (string.IsNullOrWhiteSpace(apiKey))
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
.VerifyAsync(FormatAuthorizationHeader(apiKey), cancellationToken)
.ConfigureAwait(false);
if (!verificationResult.Succeeded || verificationResult.Identity is null)
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
if (options.Value.Dashboard.RequireAdminScope
&& !verificationResult.Identity.Scopes.Contains(GatewayScopes.Admin))
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
return DashboardAuthenticationResult.Success(CreatePrincipal(verificationResult.Identity));
}
private static string FormatAuthorizationHeader(string apiKey)
{
string trimmedApiKey = apiKey.Trim();
return trimmedApiKey.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? trimmedApiKey
: $"Bearer {trimmedApiKey}";
}
private static ClaimsPrincipal CreatePrincipal(ApiKeyIdentity identity)
{
List<Claim> claims =
[
new Claim(ClaimTypes.NameIdentifier, identity.KeyId),
new Claim(ClaimTypes.Name, identity.DisplayName),
new Claim(DashboardAuthenticationDefaults.KeyPrefixClaimType, identity.KeyPrefix)
];
claims.AddRange(identity.Scopes.Select(scope => new Claim(
DashboardAuthenticationDefaults.ScopeClaimType,
scope)));
ClaimsIdentity claimsIdentity = new(
claims,
DashboardAuthenticationDefaults.AuthenticationScheme,
ClaimTypes.Name,
DashboardAuthenticationDefaults.ScopeClaimType);
return new ClaimsPrincipal(claimsIdentity);
}
}
@@ -0,0 +1,59 @@
using System.Net;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Security.Authorization;
namespace MxGateway.Server.Dashboard;
public sealed class DashboardAuthorizationHandler(
IHttpContextAccessor httpContextAccessor,
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
DashboardAuthorizationRequirement requirement)
{
GatewayOptions gatewayOptions = options.Value;
if (gatewayOptions.Authentication.Mode == AuthenticationMode.Disabled)
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (gatewayOptions.Dashboard.AllowAnonymousLocalhost && IsLoopbackRequest())
{
context.Succeed(requirement);
return Task.CompletedTask;
}
if (context.User.Identity?.IsAuthenticated != true)
{
return Task.CompletedTask;
}
if (!gatewayOptions.Dashboard.RequireAdminScope || HasAdminScope(context))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
private bool IsLoopbackRequest()
{
IPAddress? remoteAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress;
return remoteAddress is not null && IPAddress.IsLoopback(remoteAddress);
}
private static bool HasAdminScope(AuthorizationHandlerContext context)
{
return context.User.HasClaim(
DashboardAuthenticationDefaults.ScopeClaimType,
GatewayScopes.Admin);
}
}
@@ -0,0 +1,5 @@
using Microsoft.AspNetCore.Authorization;
namespace MxGateway.Server.Dashboard;
public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement;
@@ -0,0 +1,217 @@
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http.HttpResults;
using MxGateway.Server.Configuration;
namespace MxGateway.Server.Dashboard;
public static class DashboardEndpointRouteBuilderExtensions
{
public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints)
{
IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService<IConfiguration>();
IConfigurationSection dashboardSection = configuration
.GetSection($"{GatewayOptions.SectionName}:Dashboard");
if (bool.TryParse(dashboardSection["Enabled"], out bool enabled) && !enabled)
{
return endpoints;
}
string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase);
RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase);
dashboard.MapGet(
"/",
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardSnapshotService snapshotService) =>
GetDashboardHomeAsync(httpContext, antiforgery, snapshotService, pathBase))
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy)
.WithName("DashboardHome");
dashboard.MapGet(
"/login",
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase))
.AllowAnonymous()
.WithName("DashboardLogin");
dashboard.MapPost(
"/login",
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
PostLoginAsync(httpContext, antiforgery, authenticator, pathBase))
.AllowAnonymous()
.WithName("DashboardLoginPost");
dashboard.MapPost(
"/logout",
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase))
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy)
.WithName("DashboardLogout");
dashboard.MapGet("/denied", () => Results.Content(
RenderPage("Access denied", "<p>The signed-in API key is not authorized for dashboard access.</p>"),
"text/html"))
.AllowAnonymous()
.WithName("DashboardAccessDenied");
return endpoints;
}
private static ContentHttpResult GetDashboardHomeAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
IDashboardSnapshotService snapshotService,
string pathBase)
{
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
DashboardSnapshot snapshot = snapshotService.GetSnapshot();
string requestToken = tokens.RequestToken ?? string.Empty;
string body = $"""
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/logout")}" class="mb-3">
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
<button type="submit">Sign out</button>
</form>
<dl>
<dt>Open sessions</dt>
<dd>{snapshot.Sessions.Count}</dd>
<dt>Workers</dt>
<dd>{snapshot.Workers.Count}</dd>
<dt>Faults</dt>
<dd>{snapshot.Faults.Count}</dd>
</dl>
""";
return TypedResults.Content(RenderPage("MXAccess Gateway Dashboard", body), "text/html");
}
private static Task<ContentHttpResult> GetLoginAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
string pathBase)
{
string returnUrl = SanitizeReturnUrl(
httpContext.Request.Query["returnUrl"].ToString(),
pathBase);
return Task.FromResult(TypedResults.Content(
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, failureMessage: null),
"text/html"));
}
private static async Task<IResult> PostLoginAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
IDashboardAuthenticator authenticator,
string pathBase)
{
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
IFormCollection form = await httpContext.Request
.ReadFormAsync(httpContext.RequestAborted)
.ConfigureAwait(false);
string returnUrl = SanitizeReturnUrl(
form["returnUrl"].ToString(),
pathBase);
DashboardAuthenticationResult result = await authenticator
.AuthenticateAsync(form["apiKey"].ToString(), httpContext.RequestAborted)
.ConfigureAwait(false);
if (!result.Succeeded || result.Principal is null)
{
return TypedResults.Content(
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, result.FailureMessage),
"text/html",
statusCode: StatusCodes.Status401Unauthorized);
}
await httpContext
.SignInAsync(DashboardAuthenticationDefaults.AuthenticationScheme, result.Principal)
.ConfigureAwait(false);
return Results.LocalRedirect(returnUrl);
}
private static async Task<IResult> PostLogoutAsync(
HttpContext httpContext,
IAntiforgery antiforgery,
string pathBase)
{
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
await httpContext
.SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
.ConfigureAwait(false);
return Results.LocalRedirect($"{pathBase}/login");
}
private static string RenderLoginPage(
HttpContext httpContext,
IAntiforgery antiforgery,
string returnUrl,
string pathBase,
string? failureMessage)
{
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
string requestToken = tokens.RequestToken ?? string.Empty;
string alert = string.IsNullOrWhiteSpace(failureMessage)
? string.Empty
: $"<p role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
string body = $"""
{alert}
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}">
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
<label for="apiKey">API key</label>
<input id="apiKey" name="apiKey" type="password" autocomplete="off" />
<button type="submit">Sign in</button>
</form>
""";
return RenderPage("Dashboard Sign In", body);
}
private static string RenderPage(string title, string body)
{
return $"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{HtmlEncoder.Default.Encode(title)}</title>
</head>
<body>
<main>
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
{body}
</main>
</body>
</html>
""";
}
private static string NormalizePathBase(string pathBase)
{
string normalized = pathBase.TrimEnd('/');
return string.IsNullOrWhiteSpace(normalized) || !normalized.StartsWith("/", StringComparison.Ordinal)
? "/dashboard"
: normalized;
}
private static string SanitizeReturnUrl(string? returnUrl, string pathBase)
{
if (string.IsNullOrWhiteSpace(returnUrl)
|| !returnUrl.StartsWith("/", StringComparison.Ordinal)
|| returnUrl.StartsWith("//", StringComparison.Ordinal)
|| !returnUrl.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase)
|| Uri.TryCreate(returnUrl, UriKind.Absolute, out _))
{
return pathBase;
}
return returnUrl;
}
}
@@ -1,3 +1,8 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
namespace MxGateway.Server.Dashboard;
public static class DashboardServiceCollectionExtensions
@@ -5,7 +10,44 @@ public static class DashboardServiceCollectionExtensions
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
{
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
services.AddHttpContextAccessor();
services.AddAntiforgery();
services
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme);
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
.Configure<IOptions<GatewayOptions>>(ConfigureCookieOptions);
services.AddAuthorization(options =>
{
options.AddPolicy(
DashboardAuthenticationDefaults.AuthorizationPolicy,
policy => policy.AddRequirements(new DashboardAuthorizationRequirement()));
});
services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
return services;
}
private static void ConfigureCookieOptions(
CookieAuthenticationOptions cookieOptions,
IOptions<GatewayOptions> gatewayOptions)
{
string pathBase = gatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
if (string.IsNullOrWhiteSpace(pathBase))
{
pathBase = "/dashboard";
}
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
cookieOptions.Cookie.HttpOnly = true;
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
cookieOptions.Cookie.Path = "/";
cookieOptions.LoginPath = $"{pathBase}/login";
cookieOptions.LogoutPath = $"{pathBase}/logout";
cookieOptions.AccessDeniedPath = $"{pathBase}/denied";
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
cookieOptions.SlidingExpiration = true;
}
}
@@ -0,0 +1,8 @@
namespace MxGateway.Server.Dashboard;
public interface IDashboardAuthenticator
{
Task<DashboardAuthenticationResult> AuthenticateAsync(
string? apiKey,
CancellationToken cancellationToken);
}
@@ -19,6 +19,8 @@ public static class GatewayApplication
WebApplication app = builder.Build();
app.UseGatewayRequestLoggingScope();
app.UseAuthentication();
app.UseAuthorization();
app.MapGatewayEndpoints();
return app;
@@ -56,6 +58,7 @@ public static class GatewayApplication
.WithName("LiveHealth");
endpoints.MapGrpcService<MxAccessGatewayService>();
endpoints.MapGatewayDashboard();
return endpoints;
}
@@ -0,0 +1,113 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
namespace MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAuthenticatorTests
{
[Fact]
public async Task AuthenticateAsync_AdminKey_ReturnsCookiePrincipal()
{
FakeApiKeyVerifier verifier = new(SuccessWithScopes(GatewayScopes.Admin));
DashboardAuthenticator authenticator = CreateAuthenticator(verifier);
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"mxgw_operator01_super-secret",
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.NotNull(result.Principal);
Assert.Equal("operator01", result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
Assert.Equal("Operator Key", result.Principal.FindFirst(ClaimTypes.Name)?.Value);
Assert.Contains(result.Principal.Claims, claim =>
claim.Type == DashboardAuthenticationDefaults.ScopeClaimType
&& claim.Value == GatewayScopes.Admin);
Assert.Equal("Bearer mxgw_operator01_super-secret", verifier.LastAuthorizationHeader);
}
[Fact]
public async Task AuthenticateAsync_NonAdminKey_ReturnsFailureWithoutRawApiKey()
{
DashboardAuthenticator authenticator = CreateAuthenticator(new FakeApiKeyVerifier(
SuccessWithScopes(GatewayScopes.EventsRead)));
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"mxgw_operator01_super-secret",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Null(result.Principal);
Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal);
}
[Fact]
public async Task AuthenticateAsync_RequireAdminScopeFalse_AllowsAuthenticatedKey()
{
DashboardAuthenticator authenticator = CreateAuthenticator(
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)),
requireAdminScope: false);
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"mxgw_operator01_secret",
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.NotNull(result.Principal);
}
[Fact]
public async Task AuthenticateAsync_InvalidKey_ReturnsGenericFailure()
{
DashboardAuthenticator authenticator = CreateAuthenticator(new FakeApiKeyVerifier(
ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch)));
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"mxgw_operator01_super-secret",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.DoesNotContain("super-secret", result.FailureMessage, StringComparison.Ordinal);
}
private static DashboardAuthenticator CreateAuthenticator(
IApiKeyVerifier verifier,
bool requireAdminScope = true)
{
return new DashboardAuthenticator(
verifier,
Options.Create(new GatewayOptions
{
Dashboard = new DashboardOptions
{
RequireAdminScope = requireAdminScope
}
}));
}
private static ApiKeyVerificationResult SuccessWithScopes(params string[] scopes)
{
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
DisplayName: "Operator Key",
Scopes: new HashSet<string>(scopes, StringComparer.Ordinal)));
}
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
{
public string? LastAuthorizationHeader { get; private set; }
public Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken)
{
LastAuthorizationHeader = authorizationHeader;
return Task.FromResult(result);
}
}
}
@@ -0,0 +1,91 @@
using System.Net;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Security.Authorization;
namespace MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAuthorizationHandlerTests
{
[Fact]
public async Task HandleAsync_UnauthenticatedRemoteRequest_DoesNotSucceed()
{
AuthorizationHandlerContext context = await AuthorizeAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
IPAddress.Parse("10.0.0.5"),
allowAnonymousLocalhost: false);
Assert.False(context.HasSucceeded);
}
[Fact]
public async Task HandleAsync_AnonymousLocalhostAllowed_Succeeds()
{
AuthorizationHandlerContext context = await AuthorizeAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
IPAddress.Loopback,
allowAnonymousLocalhost: true);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task HandleAsync_AuthenticatedWithoutAdminScope_DoesNotSucceed()
{
AuthorizationHandlerContext context = await AuthorizeAsync(
CreatePrincipal(GatewayScopes.EventsRead),
IPAddress.Loopback,
allowAnonymousLocalhost: false);
Assert.False(context.HasSucceeded);
}
[Fact]
public async Task HandleAsync_AuthenticatedWithAdminScope_Succeeds()
{
AuthorizationHandlerContext context = await AuthorizeAsync(
CreatePrincipal(GatewayScopes.Admin),
IPAddress.Parse("10.0.0.5"),
allowAnonymousLocalhost: false);
Assert.True(context.HasSucceeded);
}
private static async Task<AuthorizationHandlerContext> AuthorizeAsync(
ClaimsPrincipal principal,
IPAddress remoteAddress,
bool allowAnonymousLocalhost)
{
DashboardAuthorizationRequirement requirement = new();
DefaultHttpContext httpContext = new();
httpContext.Connection.RemoteIpAddress = remoteAddress;
DashboardAuthorizationHandler handler = new(
new HttpContextAccessor { HttpContext = httpContext },
Options.Create(new GatewayOptions
{
Dashboard = new DashboardOptions
{
AllowAnonymousLocalhost = allowAnonymousLocalhost,
RequireAdminScope = true
}
}));
AuthorizationHandlerContext context = new([requirement], principal, httpContext);
await handler.HandleAsync(context);
return context;
}
private static ClaimsPrincipal CreatePrincipal(string scope)
{
ClaimsIdentity identity = new(
[new Claim(DashboardAuthenticationDefaults.ScopeClaimType, scope)],
DashboardAuthenticationDefaults.AuthenticationScheme);
return new ClaimsPrincipal(identity);
}
}
@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using MxGateway.Server;
using MxGateway.Server.Dashboard;
namespace MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardCookieOptionsTests
{
[Fact]
public void Build_ConfiguresSecureDashboardCookie()
{
WebApplication app = GatewayApplication.Build([]);
IOptionsMonitor<CookieAuthenticationOptions> optionsMonitor = app.Services
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
CookieAuthenticationOptions options = optionsMonitor.Get(
DashboardAuthenticationDefaults.AuthenticationScheme);
Assert.Equal(DashboardAuthenticationDefaults.CookieName, options.Cookie.Name);
Assert.True(options.Cookie.HttpOnly);
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
Assert.Equal("/", options.Cookie.Path);
Assert.Equal("/dashboard/login", options.LoginPath);
Assert.Equal("/dashboard/logout", options.LogoutPath);
Assert.Equal("/dashboard/denied", options.AccessDeniedPath);
}
}
@@ -0,0 +1,220 @@
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.MxAccess;
using MxGateway.Worker.Sta;
namespace MxGateway.Worker.Tests.MxAccess;
public sealed class MxAccessCommandExecutorTests
{
[Fact]
public async Task DispatchAsync_Register_CallsMxAccessOnStaAndPreservesServerHandle()
{
FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 42));
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(CreateRegisterCommand("correlation-1", "client-a"));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.True(reply.HasHresult);
Assert.Equal(0, reply.Hresult);
Assert.Equal(42, reply.Register.ServerHandle);
Assert.Equal(MxDataType.Integer, reply.ReturnValue.DataType);
Assert.Equal(42, reply.ReturnValue.Int32Value);
Assert.Equal(runtime.StaThreadId, factory.FakeComObject.RegisterThreadId);
Assert.Equal("client-a", factory.FakeComObject.RegisteredClientName);
RegisteredServerHandle registeredServerHandle = Assert.Single(
await session.GetRegisteredServerHandlesAsync());
Assert.Equal(42, registeredServerHandle.ServerHandle);
Assert.Equal("client-a", registeredServerHandle.ClientName);
}
[Fact]
public async Task DispatchAsync_Unregister_CallsMxAccessOnStaAndRemovesTrackedServerHandle()
{
FakeMxAccessComObject fakeComObject = new(registerHandle: 43);
FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
await session.StartAsync(workerProcessId: 1234);
await session.DispatchAsync(CreateRegisterCommand("register", "client-a"));
MxCommandReply reply = await session.DispatchAsync(CreateUnregisterCommand("unregister", 43));
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
Assert.Equal(43, fakeComObject.UnregisteredServerHandle);
Assert.Equal(runtime.StaThreadId, fakeComObject.UnregisterThreadId);
Assert.Empty(await session.GetRegisteredServerHandlesAsync());
}
[Fact]
public async Task DispatchAsync_UnregisterWhenMxAccessThrows_PreservesHResultAndDoesNotRewriteFailure()
{
const int hresult = unchecked((int)0x80070057);
FakeMxAccessComObject fakeComObject = new(
registerHandle: 44,
unregisterException: new COMException("Invalid handle.", hresult));
FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
await session.StartAsync(workerProcessId: 1234);
await session.DispatchAsync(CreateRegisterCommand("register-before-failure", "client-a"));
MxCommandReply reply = await session.DispatchAsync(CreateUnregisterCommand("invalid-unregister", 44));
Assert.Equal(ProtocolStatusCode.MxaccessFailure, reply.ProtocolStatus.Code);
Assert.True(reply.HasHresult);
Assert.Equal(hresult, reply.Hresult);
Assert.Contains("0x80070057", reply.DiagnosticMessage);
Assert.Equal(44, fakeComObject.UnregisteredServerHandle);
RegisteredServerHandle registeredServerHandle = Assert.Single(
await session.GetRegisteredServerHandlesAsync());
Assert.Equal(44, registeredServerHandle.ServerHandle);
}
[Fact]
public async Task DispatchAsync_RegisterWithoutPayload_ReturnsInvalidRequest()
{
FakeMxAccessComObjectFactory factory = new(new FakeMxAccessComObject(registerHandle: 45));
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(new StaCommand(
"session-1",
"missing-payload",
new MxCommand
{
Kind = MxCommandKind.Register,
}));
Assert.Equal(ProtocolStatusCode.InvalidRequest, reply.ProtocolStatus.Code);
Assert.Null(factory.FakeComObject.RegisteredClientName);
}
private static StaCommand CreateRegisterCommand(
string correlationId,
string clientName)
{
return new StaCommand(
"session-1",
correlationId,
new MxCommand
{
Kind = MxCommandKind.Register,
Register = new RegisterCommand
{
ClientName = clientName,
},
});
}
private static StaCommand CreateUnregisterCommand(
string correlationId,
int serverHandle)
{
return new StaCommand(
"session-1",
correlationId,
new MxCommand
{
Kind = MxCommandKind.Unregister,
Unregister = new UnregisterCommand
{
ServerHandle = serverHandle,
},
});
}
private static StaRuntime CreateRuntime()
{
return new StaRuntime(
new NoopComApartmentInitializer(),
new StaMessagePump(),
TimeSpan.FromMilliseconds(25));
}
private sealed class FakeMxAccessComObject
{
private readonly int registerHandle;
private readonly Exception? unregisterException;
public FakeMxAccessComObject(
int registerHandle,
Exception? unregisterException = null)
{
this.registerHandle = registerHandle;
this.unregisterException = unregisterException;
}
public string? RegisteredClientName { get; private set; }
public int? RegisterThreadId { get; private set; }
public int? UnregisteredServerHandle { get; private set; }
public int? UnregisterThreadId { get; private set; }
public int Register(string clientName)
{
RegisteredClientName = clientName;
RegisterThreadId = Environment.CurrentManagedThreadId;
return registerHandle;
}
public void Unregister(int serverHandle)
{
UnregisteredServerHandle = serverHandle;
UnregisterThreadId = Environment.CurrentManagedThreadId;
if (unregisterException is not null)
{
throw unregisterException;
}
}
}
private sealed class FakeMxAccessComObjectFactory : IMxAccessComObjectFactory
{
public FakeMxAccessComObjectFactory(FakeMxAccessComObject fakeComObject)
{
FakeComObject = fakeComObject;
}
public FakeMxAccessComObject FakeComObject { get; }
public object Create()
{
return FakeComObject;
}
}
private sealed class NoopEventSink : IMxAccessEventSink
{
public void Attach(object mxAccessComObject)
{
}
public void Detach()
{
}
}
private sealed class NoopComApartmentInitializer : IStaComApartmentInitializer
{
public void Initialize()
{
}
public void Uninitialize()
{
}
}
}
@@ -1,6 +1,8 @@
using System;
using System.Threading.Tasks;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.MxAccess;
using MxGateway.Worker.Sta;
namespace MxGateway.Worker.Tests.MxAccess;
@@ -21,4 +23,53 @@ public sealed class MxAccessLiveComCreationTests
await session.StartAsync(workerProcessId: 1234);
}
[Fact]
public async Task RegisterAndUnregister_WhenOptedIn_RoundTripsInstalledMxAccessServerHandle()
{
if (!RunLiveMxAccessTests())
{
return;
}
using MxAccessStaSession session = new();
await session.StartAsync(workerProcessId: 1234);
MxCommandReply registerReply = await session.DispatchAsync(new StaCommand(
"session-1",
"live-register",
new MxCommand
{
Kind = MxCommandKind.Register,
Register = new RegisterCommand
{
ClientName = "MxGateway.Worker.Tests",
},
}));
Assert.Equal(ProtocolStatusCode.Ok, registerReply.ProtocolStatus.Code);
Assert.True(registerReply.Register.ServerHandle > 0);
MxCommandReply unregisterReply = await session.DispatchAsync(new StaCommand(
"session-1",
"live-unregister",
new MxCommand
{
Kind = MxCommandKind.Unregister,
Unregister = new UnregisterCommand
{
ServerHandle = registerReply.Register.ServerHandle,
},
}));
Assert.Equal(ProtocolStatusCode.Ok, unregisterReply.ProtocolStatus.Code);
}
private static bool RunLiveMxAccessTests()
{
return string.Equals(
Environment.GetEnvironmentVariable("MXGATEWAY_RUN_LIVE_MXACCESS_TESTS"),
"1",
StringComparison.Ordinal);
}
}
@@ -0,0 +1,8 @@
namespace MxGateway.Worker.MxAccess;
public interface IMxAccessServer
{
int Register(string clientName);
void Unregister(int serverHandle);
}
@@ -0,0 +1,59 @@
using System;
using System.Reflection;
using System.Runtime.ExceptionServices;
using ArchestrA.MxAccess;
namespace MxGateway.Worker.MxAccess;
public sealed class MxAccessComServer : IMxAccessServer
{
private readonly object mxAccessComObject;
public MxAccessComServer(object mxAccessComObject)
{
this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject));
}
public int Register(string clientName)
{
if (mxAccessComObject is ILMXProxyServer mxAccessServer)
{
return mxAccessServer.Register(clientName);
}
return (int)Invoke(nameof(Register), clientName);
}
public void Unregister(int serverHandle)
{
if (mxAccessComObject is ILMXProxyServer mxAccessServer)
{
mxAccessServer.Unregister(serverHandle);
return;
}
Invoke(nameof(Unregister), serverHandle);
}
private object Invoke(
string methodName,
params object[] arguments)
{
try
{
return mxAccessComObject
.GetType()
.InvokeMember(
methodName,
BindingFlags.Instance | BindingFlags.Public | BindingFlags.InvokeMethod,
binder: null,
target: mxAccessComObject,
args: arguments);
}
catch (TargetInvocationException exception) when (exception.InnerException is not null)
{
ExceptionDispatchInfo.Capture(exception.InnerException).Throw();
throw;
}
}
}
@@ -0,0 +1,103 @@
using System;
using MxGateway.Contracts.Proto;
using MxGateway.Worker.Conversion;
using MxGateway.Worker.Sta;
namespace MxGateway.Worker.MxAccess;
public sealed class MxAccessCommandExecutor : IStaCommandExecutor
{
private readonly MxAccessSession session;
private readonly VariantConverter variantConverter;
public MxAccessCommandExecutor(MxAccessSession session)
: this(session, new VariantConverter())
{
}
public MxAccessCommandExecutor(
MxAccessSession session,
VariantConverter variantConverter)
{
this.session = session ?? throw new ArgumentNullException(nameof(session));
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
}
public MxCommandReply Execute(StaCommand command)
{
if (command is null)
{
throw new ArgumentNullException(nameof(command));
}
return command.Kind switch
{
MxCommandKind.Register => ExecuteRegister(command),
MxCommandKind.Unregister => ExecuteUnregister(command),
_ => CreateInvalidRequestReply(command, $"Unsupported MXAccess command kind {command.Kind}."),
};
}
private MxCommandReply ExecuteRegister(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Register)
{
return CreateInvalidRequestReply(command, "Register command payload is required.");
}
int serverHandle = session.Register(command.Command.Register.ClientName);
MxCommandReply reply = CreateOkReply(command);
reply.ReturnValue = variantConverter.Convert(serverHandle);
reply.Register = new RegisterReply
{
ServerHandle = serverHandle,
};
return reply;
}
private MxCommandReply ExecuteUnregister(StaCommand command)
{
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Unregister)
{
return CreateInvalidRequestReply(command, "Unregister command payload is required.");
}
session.Unregister(command.Command.Unregister.ServerHandle);
return CreateOkReply(command);
}
private static MxCommandReply CreateOkReply(StaCommand command)
{
return new MxCommandReply
{
SessionId = command.SessionId,
CorrelationId = command.CorrelationId,
Kind = command.Kind,
Hresult = 0,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.Ok,
Message = "OK",
},
};
}
private static MxCommandReply CreateInvalidRequestReply(
StaCommand command,
string message)
{
return new MxCommandReply
{
SessionId = command.SessionId,
CorrelationId = command.CorrelationId,
Kind = command.Kind,
ProtocolStatus = new ProtocolStatus
{
Code = ProtocolStatusCode.InvalidRequest,
Message = message,
},
DiagnosticMessage = message,
};
}
}
@@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.Linq;
namespace MxGateway.Worker.MxAccess;
public sealed class MxAccessHandleRegistry
{
private readonly Dictionary<int, RegisteredServerHandle> serverHandles = new();
public IReadOnlyList<RegisteredServerHandle> ServerHandles => serverHandles
.Values
.OrderBy(handle => handle.ServerHandle)
.ToArray();
public void RegisterServerHandle(
int serverHandle,
string clientName)
{
serverHandles[serverHandle] = new RegisteredServerHandle(serverHandle, clientName);
}
public void UnregisterServerHandle(int serverHandle)
{
serverHandles.Remove(serverHandle);
}
public bool ContainsServerHandle(int serverHandle)
{
return serverHandles.ContainsKey(serverHandle);
}
}
@@ -8,21 +8,29 @@ namespace MxGateway.Worker.MxAccess;
public sealed class MxAccessSession : IDisposable
{
private readonly object mxAccessComObject;
private readonly IMxAccessServer mxAccessServer;
private readonly IMxAccessEventSink eventSink;
private readonly MxAccessHandleRegistry handleRegistry;
private bool disposed;
private MxAccessSession(
object mxAccessComObject,
IMxAccessServer mxAccessServer,
IMxAccessEventSink eventSink,
MxAccessHandleRegistry handleRegistry,
int creationThreadId)
{
this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject));
this.mxAccessServer = mxAccessServer ?? throw new ArgumentNullException(nameof(mxAccessServer));
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
this.handleRegistry = handleRegistry ?? throw new ArgumentNullException(nameof(handleRegistry));
CreationThreadId = creationThreadId;
}
public int CreationThreadId { get; }
public MxAccessHandleRegistry HandleRegistry => handleRegistry;
public WorkerReady CreateWorkerReady(int workerProcessId)
{
return new WorkerReady
@@ -62,7 +70,9 @@ public sealed class MxAccessSession : IDisposable
return new MxAccessSession(
mxAccessComObject,
new MxAccessComServer(mxAccessComObject),
eventSink,
new MxAccessHandleRegistry(),
Environment.CurrentManagedThreadId);
}
catch (Exception exception)
@@ -78,6 +88,24 @@ public sealed class MxAccessSession : IDisposable
}
}
public int Register(string clientName)
{
ThrowIfDisposed();
int serverHandle = mxAccessServer.Register(clientName);
handleRegistry.RegisterServerHandle(serverHandle, clientName);
return serverHandle;
}
public void Unregister(int serverHandle)
{
ThrowIfDisposed();
mxAccessServer.Unregister(serverHandle);
handleRegistry.UnregisterServerHandle(serverHandle);
}
public void Dispose()
{
if (disposed)
@@ -94,4 +122,12 @@ public sealed class MxAccessSession : IDisposable
disposed = true;
}
private void ThrowIfDisposed()
{
if (disposed)
{
throw new ObjectDisposedException(nameof(MxAccessSession));
}
}
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MxGateway.Contracts.Proto;
@@ -11,6 +12,7 @@ public sealed class MxAccessStaSession : IDisposable
private readonly IMxAccessComObjectFactory factory;
private readonly IMxAccessEventSink eventSink;
private readonly StaRuntime staRuntime;
private StaCommandDispatcher? commandDispatcher;
private MxAccessSession? session;
private bool disposed;
@@ -47,11 +49,38 @@ public sealed class MxAccessStaSession : IDisposable
}
session = MxAccessSession.Create(factory, eventSink);
commandDispatcher = new StaCommandDispatcher(
staRuntime,
new MxAccessCommandExecutor(session));
return session.CreateWorkerReady(workerProcessId);
},
cancellationToken);
}
public Task<MxCommandReply> DispatchAsync(StaCommand command)
{
if (commandDispatcher is null)
{
throw new InvalidOperationException("MXAccess COM session has not been started.");
}
return commandDispatcher.DispatchAsync(command);
}
public Task<IReadOnlyList<RegisteredServerHandle>> GetRegisteredServerHandlesAsync(
CancellationToken cancellationToken = default)
{
if (session is null)
{
throw new InvalidOperationException("MXAccess COM session has not been started.");
}
return staRuntime.InvokeAsync(
() => session.HandleRegistry.ServerHandles,
cancellationToken);
}
public void Dispose()
{
if (disposed)
@@ -59,6 +88,8 @@ public sealed class MxAccessStaSession : IDisposable
return;
}
commandDispatcher?.RequestShutdown();
if (session is not null)
{
staRuntime.InvokeAsync(() => session.Dispose()).GetAwaiter().GetResult();
@@ -0,0 +1,16 @@
namespace MxGateway.Worker.MxAccess;
public sealed class RegisteredServerHandle
{
public RegisteredServerHandle(
int serverHandle,
string clientName)
{
ServerHandle = serverHandle;
ClientName = clientName;
}
public int ServerHandle { get; }
public string ClientName { get; }
}