Architecture remediation: P2 tier (completeness & polish) #122
@@ -123,7 +123,7 @@ Gateway gRPC clients authenticate with an API key in metadata: `authorization: B
|
|||||||
|
|
||||||
Session event streaming is **owner-scoped**: the API key that opened a session is recorded on the session, and every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless the caller's key id matches the owner. Possessing the `event` scope and knowing a session id is not sufficient — this closes the reconnect/fan-out trust boundary (detach-grace and replay retention are on by default) so an `event`-scoped key cannot attach to another key's retained session.
|
Session event streaming is **owner-scoped**: the API key that opened a session is recorded on the session, and every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless the caller's key id matches the owner. Possessing the `event` scope and knowing a session id is not sufficient — this closes the reconnect/fan-out trust boundary (detach-grace and replay retention are on by default) so an `event`-scoped key cannot attach to another key's retained session.
|
||||||
|
|
||||||
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure `__Host-MxGatewayDashboard` cookie. SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
|
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
|
||||||
|
|
||||||
## Process / Platform Notes
|
## Process / Platform Notes
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed.
|
|||||||
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
||||||
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
||||||
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
||||||
| `MxGateway:Dashboard:CookieName` | `MxGatewayDashboard` | Dashboard auth cookie name. Leave unset (null/blank) to use the default. Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. Changing it signs out existing dashboard sessions on next deploy. |
|
| `MxGateway:Dashboard:CookieName` | _(unset)_ | Dashboard auth cookie name. Leave unset (null/blank) to use the resolved default: `__Host-MxGatewayDashboard` when `RequireHttpsCookie` is `true` (Secure cookie, so the `__Host-` prefix's Secure/no-Domain/`Path=/` guarantees apply), else the plain `MxGatewayDashboard` (a `__Host-` cookie without Secure is silently dropped by browsers). Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. An explicit override always wins over the `__Host-` default. Changing it signs out existing dashboard sessions on next deploy. |
|
||||||
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
||||||
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
||||||
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
||||||
|
|||||||
@@ -435,7 +435,11 @@ Implemented behavior:
|
|||||||
`ClaimTypes.Role` claims, alongside the per-group `mxgateway:ldap_group`
|
`ClaimTypes.Role` claims, alongside the per-group `mxgateway:ldap_group`
|
||||||
claims;
|
claims;
|
||||||
- a successful login signs in the `MxGateway.Dashboard` cookie scheme
|
- a successful login signs in the `MxGateway.Dashboard` cookie scheme
|
||||||
(`__Host-MxGatewayDashboard`, HttpOnly, SameSite=Strict, Secure);
|
(HttpOnly, SameSite=Strict, Secure); the cookie is named
|
||||||
|
`__Host-MxGatewayDashboard` when `MxGateway:Dashboard:RequireHttpsCookie` is
|
||||||
|
true (default) and no `MxGateway:Dashboard:CookieName` override is set,
|
||||||
|
otherwise the plain `MxGatewayDashboard` name is used (the `__Host-` prefix is
|
||||||
|
only honoured on a Secure cookie);
|
||||||
- a user with no matching group cannot sign in — the login screen returns the
|
- a user with no matching group cannot sign in — the login screen returns the
|
||||||
generic credential-rejected message;
|
generic credential-rejected message;
|
||||||
- antiforgery tokens guard the login and logout POSTs.
|
- antiforgery tokens guard the login and logout POSTs.
|
||||||
|
|||||||
@@ -699,8 +699,11 @@ password in a form post, calls `DashboardAuthenticator` to bind against
|
|||||||
`MxGateway:Ldap`, resolves the user's LDAP groups through
|
`MxGateway:Ldap`, resolves the user's LDAP groups through
|
||||||
`MxGateway:Dashboard:GroupToRole` to one of `Admin` / `Viewer`, and signs in
|
`MxGateway:Dashboard:GroupToRole` to one of `Admin` / `Viewer`, and signs in
|
||||||
with the `MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only,
|
with the `MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only,
|
||||||
secure, strict SameSite, and named `__Host-MxGatewayDashboard`. Logout
|
secure, and strict SameSite. It is named `__Host-MxGatewayDashboard` when
|
||||||
clears it. Login and logout posts validate antiforgery tokens. SignalR
|
`MxGateway:Dashboard:RequireHttpsCookie` is true (default) and no
|
||||||
|
`MxGateway:Dashboard:CookieName` override is set; otherwise it falls back to the
|
||||||
|
plain `MxGatewayDashboard` name (the `__Host-` prefix requires a Secure cookie).
|
||||||
|
Logout clears it. Login and logout posts validate antiforgery tokens. SignalR
|
||||||
connections additionally accept a 30-minute data-protected bearer minted at
|
connections additionally accept a 30-minute data-protected bearer minted at
|
||||||
`/hubs/token`. `Dashboard:AllowAnonymousLocalhost` permits loopback requests
|
`/hubs/token`. `Dashboard:AllowAnonymousLocalhost` permits loopback requests
|
||||||
to bypass the cookie requirement and defaults to `true`.
|
to bypass the cookie requirement and defaults to `true`.
|
||||||
|
|||||||
@@ -451,7 +451,9 @@ Deliverables:
|
|||||||
- LDAP bind against `MxGateway:Ldap`,
|
- LDAP bind against `MxGateway:Ldap`,
|
||||||
- LDAP-group → role mapping (`Admin` / `Viewer`) via
|
- LDAP-group → role mapping (`Admin` / `Viewer`) via
|
||||||
`MxGateway:Dashboard:GroupToRole`,
|
`MxGateway:Dashboard:GroupToRole`,
|
||||||
- HTTP-only secure cookie (`__Host-MxGatewayDashboard`),
|
- HTTP-only secure cookie (`__Host-MxGatewayDashboard` when
|
||||||
|
`RequireHttpsCookie` is true and no `CookieName` override; else
|
||||||
|
`MxGatewayDashboard`),
|
||||||
- `/hubs/token` bearer mint for SignalR connections,
|
- `/hubs/token` bearer mint for SignalR connections,
|
||||||
- `/logout`,
|
- `/logout`,
|
||||||
- antiforgery protection,
|
- antiforgery protection,
|
||||||
|
|||||||
+6
-1
@@ -219,7 +219,12 @@ Dashboard authentication is LDAP-backed (distinct from the API-key model on
|
|||||||
the gRPC API). `/login` accepts username and password in a form body, binds
|
the gRPC API). `/login` accepts username and password in a form body, binds
|
||||||
against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer`
|
against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer`
|
||||||
via `MxGateway:Dashboard:GroupToRole`, and issues an HTTP-only secure
|
via `MxGateway:Dashboard:GroupToRole`, and issues an HTTP-only secure
|
||||||
`__Host-MxGatewayDashboard` cookie. `/logout` clears it. Login and logout
|
cookie. The cookie is named `__Host-MxGatewayDashboard` when
|
||||||
|
`MxGateway:Dashboard:RequireHttpsCookie` is true (the default, forcing Secure)
|
||||||
|
and no `MxGateway:Dashboard:CookieName` override is set; otherwise it is named
|
||||||
|
`MxGatewayDashboard` (the `__Host-` prefix is only valid on a Secure cookie, so
|
||||||
|
it is dropped for HTTP-dev or custom-name deployments). `/logout` clears it.
|
||||||
|
Login and logout
|
||||||
posts validate antiforgery tokens. SignalR hub connections accept either the
|
posts validate antiforgery tokens. SignalR hub connections accept either the
|
||||||
cookie or a 30-minute data-protected bearer minted at `/hubs/token`.
|
cookie or a 30-minute data-protected bearer minted at `/hubs/token`.
|
||||||
`MxGateway:Dashboard:AllowAnonymousLocalhost` permits loopback to bypass the
|
`MxGateway:Dashboard:AllowAnonymousLocalhost` permits loopback to bypass the
|
||||||
|
|||||||
@@ -35,5 +35,23 @@ public static class DashboardAuthenticationDefaults
|
|||||||
|
|
||||||
public const string LdapGroupClaimType = "mxgateway:ldap_group";
|
public const string LdapGroupClaimType = "mxgateway:ldap_group";
|
||||||
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dashboard auth cookie name used when the cookie is not guaranteed to be Secure
|
||||||
|
/// (<c>RequireHttpsCookie=false</c> → <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieSecurePolicy.SameAsRequest"/>)
|
||||||
|
/// or when an explicit <c>MxGateway:Dashboard:CookieName</c> override is absent but the
|
||||||
|
/// secure default cannot be applied. This plain name carries no browser-enforced guarantees.
|
||||||
|
/// </summary>
|
||||||
public const string CookieName = "MxGatewayDashboard";
|
public const string CookieName = "MxGatewayDashboard";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Dashboard auth cookie name applied when the cookie is guaranteed Secure
|
||||||
|
/// (<c>RequireHttpsCookie=true</c> → <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieSecurePolicy.Always"/>)
|
||||||
|
/// and no explicit <c>MxGateway:Dashboard:CookieName</c> override is set. The <c>__Host-</c>
|
||||||
|
/// prefix instructs browsers to enforce Secure, no <c>Domain</c>, and <c>Path=/</c>; those
|
||||||
|
/// guarantees only hold for a Secure cookie, so this name must never be applied unless
|
||||||
|
/// <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieSecurePolicy.Always"/> is in
|
||||||
|
/// effect — a <c>__Host-</c> cookie without Secure is silently dropped by browsers.
|
||||||
|
/// </summary>
|
||||||
|
public const string SecureCookieName = "__Host-MxGatewayDashboard";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,13 +123,22 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
? CookieSecurePolicy.Always
|
? CookieSecurePolicy.Always
|
||||||
: CookieSecurePolicy.SameAsRequest;
|
: CookieSecurePolicy.SameAsRequest;
|
||||||
|
|
||||||
// Config-driven cookie name (MxGateway:Dashboard:CookieName). Null/blank keeps
|
// Config-driven cookie name (MxGateway:Dashboard:CookieName). An explicit override
|
||||||
// the canonical default set above, so a misconfiguration cannot unname the cookie.
|
// always wins. With no override, restore the __Host- prefix when the cookie is
|
||||||
|
// guaranteed Secure (RequireHttpsCookie true → SecurePolicy Always): the __Host-
|
||||||
|
// browser guarantees (Secure required, no Domain, Path=/) hold only for a Secure
|
||||||
|
// cookie, and a __Host- cookie without Secure is silently dropped — so the prefix
|
||||||
|
// is never applied unless SecurePolicy is Always. Otherwise keep the plain
|
||||||
|
// canonical default set by AddCookie above, so a misconfiguration cannot unname it.
|
||||||
var cookieName = gatewayOptions.Value.Dashboard.CookieName;
|
var cookieName = gatewayOptions.Value.Dashboard.CookieName;
|
||||||
if (!string.IsNullOrWhiteSpace(cookieName))
|
if (!string.IsNullOrWhiteSpace(cookieName))
|
||||||
{
|
{
|
||||||
cookieOptions.Cookie.Name = cookieName;
|
cookieOptions.Cookie.Name = cookieName;
|
||||||
}
|
}
|
||||||
|
else if (cookieOptions.Cookie.SecurePolicy == CookieSecurePolicy.Always)
|
||||||
|
{
|
||||||
|
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.SecureCookieName;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddAuthorization(authorization =>
|
services.AddAuthorization(authorization =>
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
|||||||
|
|
||||||
public sealed class DashboardCookieOptionsTests
|
public sealed class DashboardCookieOptionsTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that the application configures secure dashboard authentication cookies.</summary>
|
/// <summary>
|
||||||
|
/// Verifies that the application configures secure dashboard authentication cookies.
|
||||||
|
/// With <c>RequireHttpsCookie</c> defaulting to <see langword="true"/> and no explicit
|
||||||
|
/// <c>CookieName</c> override, the cookie is named with the <c>__Host-</c> prefix so
|
||||||
|
/// browsers enforce the Secure/no-Domain/Path=/ guarantees the prefix promises.
|
||||||
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Build_ConfiguresSecureDashboardCookie()
|
public async Task Build_ConfiguresSecureDashboardCookie()
|
||||||
@@ -22,7 +27,7 @@ public sealed class DashboardCookieOptionsTests
|
|||||||
CookieAuthenticationOptions options = optionsMonitor.Get(
|
CookieAuthenticationOptions options = optionsMonitor.Get(
|
||||||
DashboardAuthenticationDefaults.AuthenticationScheme);
|
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
Assert.Equal(DashboardAuthenticationDefaults.CookieName, options.Cookie.Name);
|
Assert.Equal(DashboardAuthenticationDefaults.SecureCookieName, options.Cookie.Name);
|
||||||
Assert.True(options.Cookie.HttpOnly);
|
Assert.True(options.Cookie.HttpOnly);
|
||||||
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
|
Assert.Equal(CookieSecurePolicy.Always, options.Cookie.SecurePolicy);
|
||||||
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
|
Assert.Equal(SameSiteMode.Strict, options.Cookie.SameSite);
|
||||||
@@ -35,11 +40,14 @@ public sealed class DashboardCookieOptionsTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that setting <c>MxGateway:Dashboard:RequireHttpsCookie=false</c>
|
/// Verifies that setting <c>MxGateway:Dashboard:RequireHttpsCookie=false</c>
|
||||||
/// relaxes the cookie to <see cref="CookieSecurePolicy.SameAsRequest"/> so
|
/// relaxes the cookie to <see cref="CookieSecurePolicy.SameAsRequest"/> so
|
||||||
/// the dashboard can be reached over plain HTTP in dev.
|
/// the dashboard can be reached over plain HTTP in dev, and that the plain
|
||||||
|
/// <see cref="DashboardAuthenticationDefaults.CookieName"/> is used rather than the
|
||||||
|
/// <c>__Host-</c> name — a <c>__Host-</c> cookie without a guaranteed Secure flag is
|
||||||
|
/// silently dropped by browsers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Build_WithRequireHttpsCookieFalse_UsesSameAsRequest()
|
public async Task Build_WithRequireHttpsCookieFalse_UsesSameAsRequestAndPlainName()
|
||||||
{
|
{
|
||||||
await using WebApplication app = GatewayApplication.Build(
|
await using WebApplication app = GatewayApplication.Build(
|
||||||
["--MxGateway:Dashboard:RequireHttpsCookie=false"]);
|
["--MxGateway:Dashboard:RequireHttpsCookie=false"]);
|
||||||
@@ -50,12 +58,14 @@ public sealed class DashboardCookieOptionsTests
|
|||||||
DashboardAuthenticationDefaults.AuthenticationScheme);
|
DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
|
||||||
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
|
Assert.Equal(CookieSecurePolicy.SameAsRequest, options.Cookie.SecurePolicy);
|
||||||
|
Assert.Equal(DashboardAuthenticationDefaults.CookieName, options.Cookie.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that <c>MxGateway:Dashboard:CookieName</c> overrides the dashboard auth
|
/// Verifies that an explicit <c>MxGateway:Dashboard:CookieName</c> override wins over the
|
||||||
/// cookie name, so a gateway instance sharing a hostname with another can be given a
|
/// secure <c>__Host-</c> default (this build leaves <c>RequireHttpsCookie</c> at its
|
||||||
/// distinct name (browser cookies are scoped by host+path, not port).
|
/// <see langword="true"/> default), so a gateway instance sharing a hostname with another
|
||||||
|
/// can be given a distinct name (browser cookies are scoped by host+path, not port).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user