Architecture remediation: P1 tier (process & hardening) #121
@@ -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 30-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` bypasses auth on loopback when enabled. `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 `__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.
|
||||||
|
|
||||||
## Process / Platform Notes
|
## Process / Platform Notes
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
|||||||
| ID | Sev | Tier | Eff | Dep | Status | Title |
|
| ID | Sev | Tier | Eff | Dep | Status | Title |
|
||||||
|---|---|:-:|:-:|---|---|---|
|
|---|---|:-:|:-:|---|---|---|
|
||||||
| SEC-01 | Medium | P1 | M | — | Done | Windows-absolute default paths become relative files off-Windows |
|
| SEC-01 | Medium | P1 | M | — | Done | Windows-absolute default paths become relative files off-Windows |
|
||||||
| SEC-02 | Medium | P1 | S | — | Not started | `AllowAnonymousLocalhost` satisfies AdminOnly, not just Viewer |
|
| SEC-02 | Medium | P1 | S | — | Done | `AllowAnonymousLocalhost` satisfies AdminOnly, not just Viewer |
|
||||||
| SEC-03 | Medium | P2 | S | — | Not started | Dashboard cookie lost its `__Host-` prefix; four docs still promise it |
|
| SEC-03 | Medium | P2 | S | — | Not started | Dashboard cookie lost its `__Host-` prefix; four docs still promise it |
|
||||||
| SEC-04 | Medium | P1 | S | SEC-03 | Done | `DisableLogin` has no production guard |
|
| SEC-04 | Medium | P1 | S | SEC-03 | Done | `DisableLogin` has no production guard |
|
||||||
| SEC-05 | Medium | P1 | M | — | Done | Hub bearer tokens irrevocable for 30 min, carried in query string |
|
| SEC-05 | Medium | P1 | M | — | Done | Hub bearer tokens irrevocable for 30 min, carried in query string |
|
||||||
@@ -150,7 +150,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
|||||||
| SEC-09 | Medium | P2 | S | — | Not started | Dashboard design-doc `GroupToRole` sample now fails startup validation |
|
| SEC-09 | Medium | P2 | S | — | Not started | Dashboard design-doc `GroupToRole` sample now fails startup validation |
|
||||||
| SEC-10 | Medium | P1 | L | — | Done | No API-key expiry |
|
| SEC-10 | Medium | P1 | L | — | Done | No API-key expiry |
|
||||||
| SEC-11 | Medium | P1 | M | SEC-08 | Done | No rate limiting or lockout on either auth surface |
|
| SEC-11 | Medium | P1 | M | SEC-08 | Done | No rate limiting or lockout on either auth surface |
|
||||||
| SEC-12 | Medium | P1 | M | — | Not started | Dashboard Close/Kill bypass the canonical audit store |
|
| SEC-12 | Medium | P1 | M | — | Done | Dashboard Close/Kill bypass the canonical audit store |
|
||||||
| SEC-13 | Low | — | S | SEC-30 | Not started | Redactor's credential-command list omits secured-bulk variants |
|
| SEC-13 | Low | — | S | SEC-30 | Not started | Redactor's credential-command list omits secured-bulk variants |
|
||||||
| SEC-14 | Low | — | S | SEC-02,20 | Not started | `/metrics` + `/health` unauthenticated; a metric leaks session ids |
|
| SEC-14 | Low | — | S | SEC-02,20 | Not started | `/metrics` + `/health` unauthenticated; a metric leaks session ids |
|
||||||
| SEC-15 | Low | — | S | — | Not started | GET `/logout` skips antiforgery |
|
| SEC-15 | Low | — | S | — | Not started | GET `/logout` skips antiforgery |
|
||||||
@@ -158,7 +158,7 @@ Full design + implementation for each row lives in the linked domain doc under i
|
|||||||
| SEC-17 | Low | — | S | — | Not started | Interceptor does not override client-streaming/duplex handlers |
|
| SEC-17 | Low | — | S | — | Not started | Interceptor does not override client-streaming/duplex handlers |
|
||||||
| SEC-18 | Low | — | S | — | Not started | Pepper-unavailable detection matches library message text |
|
| SEC-18 | Low | — | S | — | Not started | Pepper-unavailable detection matches library message text |
|
||||||
| SEC-19 | Low | — | S | — | Not started | Canonical audit store re-issues `CREATE TABLE` per write/read |
|
| SEC-19 | Low | — | S | — | Not started | Canonical audit store re-issues `CREATE TABLE` per write/read |
|
||||||
| SEC-20 | Low | P1 | S | — | Not started | `session_id` metric tag is unbounded cardinality |
|
| SEC-20 | Low | P1 | S | — | Done | `session_id` metric tag is unbounded cardinality |
|
||||||
| SEC-21 | Low | — | M | — | Not started | Snapshot publisher works every second regardless of audience |
|
| SEC-21 | Low | — | M | — | Not started | Snapshot publisher works every second regardless of audience |
|
||||||
| SEC-22 | Low | P2 | M | — | Not started | `docs/Authentication.md` documents types no longer in this repo |
|
| SEC-22 | Low | P2 | M | — | Not started | `docs/Authentication.md` documents types no longer in this repo |
|
||||||
| SEC-23 | Low | — | S | SEC-03,04 | Not started | Validator ignores `DisableLogin`/`AutoLoginUser`/`RequireHttpsCookie`/`CookieName` |
|
| SEC-23 | Low | — | S | SEC-03,04 | Not started | Validator ignores `DisableLogin`/`AutoLoginUser`/`RequireHttpsCookie`/`CookieName` |
|
||||||
@@ -255,6 +255,7 @@ Findings the review flagged as one coordinated design pass — sequence them tog
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 2026-07-09 | Initial tracking doc generated from the six domain remediation designs. All 153 findings `Not started`. |
|
| 2026-07-09 | Initial tracking doc generated from the six domain remediation designs. All 153 findings `Not started`. |
|
||||||
| 2026-07-09 | P0 tier executed via parallel agents. GWC-01/02/03, TST-02, TST-12, CLI-01, CLI-03 → `Done` (NonWindows build clean; gateway 135/135 targeted tests pass, Go `go test ./...` + `-race` pass, Rust `test`/`clippy -D warnings` pass). |
|
| 2026-07-09 | P0 tier executed via parallel agents. GWC-01/02/03, TST-02, TST-12, CLI-01, CLI-03 → `Done` (NonWindows build clean; gateway 135/135 targeted tests pass, Go `go test ./...` + `-race` pass, Rust `test`/`clippy -D warnings` pass). |
|
||||||
|
| 2026-07-09 | P1 S-misc (dashboard/observability hardening). SEC-02/12/20 → `Done`. SEC-02: `DashboardAuthorizationHandler` restricts the loopback + `Authentication:Mode=Disabled` bypasses to read-only (they satisfy a Viewer-bearing requirement but never `AdminOnly`), closing the policy-layer gap where anonymous localhost was authorized for Admin surfaces. SEC-12: `DashboardSessionAdminService` now emits canonical `AuditEvent`s (`dashboard-close-session`/`dashboard-kill-worker`, category `SessionAdmin`) through `IAuditWriter` on Success/Failure/Denied, so Close/Kill land durable audit rows. SEC-20: dropped the unbounded `session_id` tag from the exported `mxgateway.heartbeats.failed` counter. Docs updated same-change (CLAUDE.md, GatewayDashboardDesign.md, Metrics.md). Server build clean (0 warnings); targeted classes 30/30 pass; broader Dashboard+Security+GatewayApplication+Metrics sweep 295/295 pass. |
|
||||||
| 2026-07-09 | P1 Wave 2b (security authz+hub). SEC-05/07/08/11 → `Done` (hub-token lifetime; QueryActiveAlarms scope arm; gateway-side verification cache + last-used coalescing; login rate limit + per-peer gRPC failure limiter). Full-suite checkpoint caught + fixed regressions the earlier narrow SEC-01/04/06 filter missed: cross-platform path-rooting, an `IHostEnvironment` fallback for minimal DI containers, a test-assembly `ASPNETCORE_ENVIRONMENT=Development` default, and a platform-correct default-path assertion. Suite: 747 passed / 42 failed, all 42 pre-existing macOS named-pipe-harness env failures. |
|
| 2026-07-09 | P1 Wave 2b (security authz+hub). SEC-05/07/08/11 → `Done` (hub-token lifetime; QueryActiveAlarms scope arm; gateway-side verification cache + last-used coalescing; login rate limit + per-peer gRPC failure limiter). Full-suite checkpoint caught + fixed regressions the earlier narrow SEC-01/04/06 filter missed: cross-platform path-rooting, an `IHostEnvironment` fallback for minimal DI containers, a test-assembly `ASPNETCORE_ENVIRONMENT=Development` default, and a platform-correct default-path assertion. Suite: 747 passed / 42 failed, all 42 pre-existing macOS named-pipe-harness env failures. |
|
||||||
| 2026-07-09 | P1 Wave 2a (security). SEC-01/04/06 → `Done` (config path-rooting + production validator guards; Server build clean, validator+hygiene tests 53/53). SEC-10 → `Done`: the shared `ZB.MOM.WW.Auth.ApiKeys` gained optional `ExpiresUtc` (expired keys rejected, auth DB auto-migrates to schema v3) via a concurrent HistorianGateway-remediation session's "G-2"; this repo consumes it by bumping the four `Auth.*` refs 0.1.2→0.1.4 (commit 197731a). Remaining SEC-10 polish (`apikey create --expires` + dashboard staleness badge) tracked as a small follow-up. |
|
| 2026-07-09 | P1 Wave 2a (security). SEC-01/04/06 → `Done` (config path-rooting + production validator guards; Server build clean, validator+hygiene tests 53/53). SEC-10 → `Done`: the shared `ZB.MOM.WW.Auth.ApiKeys` gained optional `ExpiresUtc` (expired keys rejected, auth DB auto-migrates to schema v3) via a concurrent HistorianGateway-remediation session's "G-2"; this repo consumes it by bumping the four `Auth.*` refs 0.1.2→0.1.4 (commit 197731a). Remaining SEC-10 polish (`apikey create --expires` + dashboard staleness badge) tracked as a small follow-up. |
|
||||||
| 2026-07-09 | P1 Wave 1 (CI + codegen freshness + Rust buildability) via parallel agents. IPC-01/09/19/20, CLI-02 → `Done`; TST-03 → `In review` (CI pipeline authored + YAML/layout-validated, but not yet executed on a Gitea runner — proven on first push). Verified on macOS: NonWindows build clean, `ClientProtoInputTests` 5/5, `publish-client-proto-inputs.ps1 -Check` exit 0, `cargo package` (no `--no-verify`) compiles standalone. Added a vendored-Rust-proto drift guard (Check 3) to `check-codegen.ps1` closing the CLI-02 static-copy risk. IPC-09 script guards not executed end-to-end (need pinned python/JRE toolchains); logic is PATH-resolution + version assertion. |
|
| 2026-07-09 | P1 Wave 1 (CI + codegen freshness + Rust buildability) via parallel agents. IPC-01/09/19/20, CLI-02 → `Done`; TST-03 → `In review` (CI pipeline authored + YAML/layout-validated, but not yet executed on a Gitea runner — proven on first push). Verified on macOS: NonWindows build clean, `ClientProtoInputTests` 5/5, `publish-client-proto-inputs.ps1 -Check` exit 0, `cargo package` (no `--no-verify`) compiles standalone. Added a vendored-Rust-proto drift guard (Check 3) to `check-codegen.ps1` closing the CLI-02 static-copy risk. IPC-09 script guards not executed end-to-end (need pinned python/JRE toolchains); logic is PATH-resolution + version assertion. |
|
||||||
|
|||||||
@@ -257,6 +257,15 @@ gated by a confirmation dialog before it reaches `ISessionManager`.
|
|||||||
is killed immediately with no graceful-shutdown attempt. The session is
|
is killed immediately with no graceful-shutdown attempt. The session is
|
||||||
removed from the registry and the open-session slot is released either way.
|
removed from the registry and the open-session slot is released either way.
|
||||||
|
|
||||||
|
Both actions write a canonical `AuditEvent` through `IAuditWriter` into the
|
||||||
|
`audit_event` store (category `SessionAdmin`, actions `dashboard-close-session`
|
||||||
|
and `dashboard-kill-worker`) in addition to the operational `ILogger` line — so a
|
||||||
|
worker killed mid-production leaves a durable, queryable row rather than only a
|
||||||
|
rotatable log entry. The event records the LDAP actor, the session id (`Target`),
|
||||||
|
the remote address, and an outcome of `Success`, `Failure`, or `Denied`
|
||||||
|
(an unauthorized attempt is still audited as `Denied`). This mirrors the API-key
|
||||||
|
management audit path.
|
||||||
|
|
||||||
### Workers page
|
### Workers page
|
||||||
|
|
||||||
Show:
|
Show:
|
||||||
@@ -436,11 +445,16 @@ Three authorization policies are registered:
|
|||||||
cookie OR a `MxGateway.Dashboard.HubToken` bearer (used by WebSocket upgrades
|
cookie OR a `MxGateway.Dashboard.HubToken` bearer (used by WebSocket upgrades
|
||||||
where the cookie can't be forwarded).
|
where the cookie can't be forwarded).
|
||||||
|
|
||||||
Two environmental bypasses still apply: `MxGateway:Authentication:Mode = Disabled`
|
Two environmental bypasses still apply, both scoped to **read-only** access:
|
||||||
authorizes every request, and `MxGateway:Dashboard:AllowAnonymousLocalhost`
|
`MxGateway:Authentication:Mode = Disabled` and `MxGateway:Dashboard:AllowAnonymousLocalhost`
|
||||||
(default `true`) authorizes any loopback request without a role check. Remote
|
(default `true`, loopback only) each satisfy a requirement that includes the Viewer
|
||||||
requests always require an authenticated principal carrying at least the
|
role (`AnyDashboardRole`) but **never** the `AdminOnly` requirement. Destructive/admin
|
||||||
Viewer role.
|
surfaces (API-key CRUD, session Close, worker Kill) still demand a real `Administrator`
|
||||||
|
role claim, so the "anonymous localhost is read-only" contract holds at the policy layer
|
||||||
|
rather than only in downstream service re-checks. Remote requests (outside the `Disabled`
|
||||||
|
bypass) always require an authenticated principal carrying at least the Viewer role. The
|
||||||
|
loopback test trusts `Connection.RemoteIpAddress`; if forwarded-headers middleware is ever
|
||||||
|
added upstream, `DashboardAuthorizationHandler.IsLoopbackRequest()` must be revisited.
|
||||||
|
|
||||||
### DisableLogin dev bypass
|
### DisableLogin dev bypass
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ All counters are `Counter<long>`. Tag values come from the call sites listed und
|
|||||||
| `mxgateway.faults` | `category` | Faults reported by session, event, or worker code paths. The category is a `SessionManagerErrorCode` or `WorkerClientErrorCode` name. |
|
| `mxgateway.faults` | `category` | Faults reported by session, event, or worker code paths. The category is a `SessionManagerErrorCode` or `WorkerClientErrorCode` name. |
|
||||||
| `mxgateway.workers.killed` | `reason` | Forced terminations of worker processes. |
|
| `mxgateway.workers.killed` | `reason` | Forced terminations of worker processes. |
|
||||||
| `mxgateway.workers.exited` | `reason` | Clean or fault-driven worker exits. |
|
| `mxgateway.workers.exited` | `reason` | Clean or fault-driven worker exits. |
|
||||||
| `mxgateway.heartbeats.failed` | `session_id` | Worker heartbeat misses tracked per session. |
|
| `mxgateway.heartbeats.failed` | *(none)* | Aggregate worker heartbeat misses. The exported counter carries **no** `session_id` tag — per-session attribution is unbounded cardinality (every session mints a new time series), so it is kept out of the exporter; use the dashboard snapshot or the log scope for per-session detail. |
|
||||||
| `mxgateway.grpc.streams.disconnected` | `reason` | Detachments of the dashboard or client gRPC event stream. |
|
| `mxgateway.grpc.streams.disconnected` | `reason` | Detachments of the dashboard or client gRPC event stream. |
|
||||||
| `mxgateway.retries.attempted` | `area` | Resilience retries executed by gateway components. |
|
| `mxgateway.retries.attempted` | `area` | Resilience retries executed by gateway components. |
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|||||||
/// (b) authentication is fully disabled, or (c) the request is from loopback
|
/// (b) authentication is fully disabled, or (c) the request is from loopback
|
||||||
/// and <c>MxGateway:Dashboard:AllowAnonymousLocalhost</c> is on.
|
/// and <c>MxGateway:Dashboard:AllowAnonymousLocalhost</c> is on.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The environment bypasses in (b) and (c) grant <em>read-only</em> access only: they
|
||||||
|
/// satisfy a requirement that includes <see cref="DashboardRoles.Viewer"/> (i.e.
|
||||||
|
/// <see cref="DashboardAuthorizationRequirement.AnyDashboardRole"/>) but never the
|
||||||
|
/// <see cref="DashboardAuthorizationRequirement.AdminOnly"/> requirement. Destructive/admin
|
||||||
|
/// surfaces still require a real Admin role claim, preserving the documented
|
||||||
|
/// "anonymous localhost is read-only" contract at the policy layer rather than by accident
|
||||||
|
/// in downstream service re-checks. The loopback test trusts
|
||||||
|
/// <c>Connection.RemoteIpAddress</c>; if forwarded-headers middleware is ever added upstream,
|
||||||
|
/// <see cref="IsLoopbackRequest"/> must be revisited so a spoofed <c>X-Forwarded-For</c>
|
||||||
|
/// cannot masquerade as loopback.
|
||||||
|
/// </remarks>
|
||||||
public sealed class DashboardAuthorizationHandler(
|
public sealed class DashboardAuthorizationHandler(
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
|
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
|
||||||
@@ -22,14 +34,18 @@ public sealed class DashboardAuthorizationHandler(
|
|||||||
{
|
{
|
||||||
GatewayOptions gatewayOptions = options.Value;
|
GatewayOptions gatewayOptions = options.Value;
|
||||||
|
|
||||||
if (gatewayOptions.Authentication.Mode == AuthenticationMode.Disabled)
|
// Environment bypasses are read-only: they satisfy the Viewer requirement
|
||||||
|
// (AnyDashboardRole) but never AdminOnly, which lacks Viewer in RequiredRoles.
|
||||||
|
bool grantsReadOnly = requirement.RequiredRoles.Contains(DashboardRoles.Viewer);
|
||||||
|
|
||||||
|
if (grantsReadOnly && gatewayOptions.Authentication.Mode == AuthenticationMode.Disabled)
|
||||||
{
|
{
|
||||||
context.Succeed(requirement);
|
context.Succeed(requirement);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gatewayOptions.Dashboard.AllowAnonymousLocalhost && IsLoopbackRequest())
|
if (grantsReadOnly && gatewayOptions.Dashboard.AllowAnonymousLocalhost && IsLoopbackRequest())
|
||||||
{
|
{
|
||||||
context.Succeed(requirement);
|
context.Succeed(requirement);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
@@ -8,21 +10,35 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default implementation of <see cref="IDashboardSessionAdminService"/>: gates
|
/// Default implementation of <see cref="IDashboardSessionAdminService"/>: gates
|
||||||
/// destructive session actions on the <see cref="DashboardRoles.Admin"/> role,
|
/// destructive session actions on the <see cref="DashboardRoles.Admin"/> role,
|
||||||
/// audit-logs successful operations, and converts <see cref="SessionManagerException"/>
|
/// records each attempt as a canonical <see cref="AuditEvent"/> through <see cref="IAuditWriter"/>
|
||||||
/// (and any other unexpected exceptions) into <see cref="DashboardSessionAdminResult.Fail(string)"/>
|
/// (in addition to the operational <see cref="ILogger"/> line), and converts
|
||||||
/// so the Blazor pages never see a raw exception.
|
/// <see cref="SessionManagerException"/> (and any other unexpected exceptions) into
|
||||||
|
/// <see cref="DashboardSessionAdminResult.Fail(string)"/> so the Blazor pages never see a raw exception.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The constant <c>dashboard-admin-kill</c> is the reason passed to
|
/// The constant <c>dashboard-admin-kill</c> is the reason passed to
|
||||||
/// <see cref="ISessionManager.KillWorkerAsync"/> and forwarded as the
|
/// <see cref="ISessionManager.KillWorkerAsync"/> and forwarded as the
|
||||||
/// <c>reason</c> tag on the <c>mxgateway.workers.killed</c> counter and in
|
/// <c>reason</c> tag on the <c>mxgateway.workers.killed</c> counter and in
|
||||||
/// the worker-kill audit log entries.
|
/// the worker-kill audit log entries. Destructive dashboard actions write durable,
|
||||||
|
/// queryable audit rows (actions <c>dashboard-close-session</c> / <c>dashboard-kill-worker</c>)
|
||||||
|
/// to the canonical <c>audit_event</c> store, mirroring the API-key management path so a
|
||||||
|
/// worker killed mid-production is not visible only in a rotatable <see cref="ILogger"/> line.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class DashboardSessionAdminService(
|
public sealed class DashboardSessionAdminService(
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
IAuditWriter auditWriter,
|
||||||
ILogger<DashboardSessionAdminService>? logger = null) : IDashboardSessionAdminService
|
ILogger<DashboardSessionAdminService>? logger = null) : IDashboardSessionAdminService
|
||||||
{
|
{
|
||||||
|
/// <summary>Canonical <see cref="AuditEvent.Category"/> for dashboard session-admin actions.</summary>
|
||||||
|
internal const string SessionAdminCategory = "SessionAdmin";
|
||||||
|
|
||||||
|
/// <summary>Canonical <see cref="AuditEvent.Action"/> for a dashboard session close.</summary>
|
||||||
|
internal const string CloseSessionAction = "dashboard-close-session";
|
||||||
|
|
||||||
|
/// <summary>Canonical <see cref="AuditEvent.Action"/> for a dashboard worker kill.</summary>
|
||||||
|
internal const string KillWorkerAction = "dashboard-kill-worker";
|
||||||
|
|
||||||
private const string UnauthorizedMessage = "Sign in as an Admin to close sessions or kill workers.";
|
private const string UnauthorizedMessage = "Sign in as an Admin to close sessions or kill workers.";
|
||||||
private const string KillReason = "dashboard-admin-kill";
|
private const string KillReason = "dashboard-admin-kill";
|
||||||
|
|
||||||
@@ -46,6 +62,8 @@ public sealed class DashboardSessionAdminService(
|
|||||||
{
|
{
|
||||||
if (!CanManage(user))
|
if (!CanManage(user))
|
||||||
{
|
{
|
||||||
|
await WriteAuditAsync(user, CloseSessionAction, sessionId, AuditOutcome.Denied, "unauthorized", cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
return DashboardSessionAdminResult.Fail(UnauthorizedMessage);
|
return DashboardSessionAdminResult.Fail(UnauthorizedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +86,15 @@ public sealed class DashboardSessionAdminService(
|
|||||||
ResolveRemoteAddress(),
|
ResolveRemoteAddress(),
|
||||||
result.AlreadyClosed);
|
result.AlreadyClosed);
|
||||||
|
|
||||||
|
await WriteAuditAsync(
|
||||||
|
user,
|
||||||
|
CloseSessionAction,
|
||||||
|
sessionId,
|
||||||
|
AuditOutcome.Success,
|
||||||
|
result.AlreadyClosed ? "alreadyClosed" : "closed",
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return DashboardSessionAdminResult.Success(
|
return DashboardSessionAdminResult.Success(
|
||||||
result.AlreadyClosed
|
result.AlreadyClosed
|
||||||
? $"Session {sessionId} was already closed."
|
? $"Session {sessionId} was already closed."
|
||||||
@@ -75,6 +102,8 @@ public sealed class DashboardSessionAdminService(
|
|||||||
}
|
}
|
||||||
catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound)
|
catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound)
|
||||||
{
|
{
|
||||||
|
await WriteAuditAsync(user, CloseSessionAction, sessionId, AuditOutcome.Failure, "not-found", cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found.");
|
return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found.");
|
||||||
}
|
}
|
||||||
catch (SessionManagerException exception)
|
catch (SessionManagerException exception)
|
||||||
@@ -84,6 +113,8 @@ public sealed class DashboardSessionAdminService(
|
|||||||
"Dashboard admin {Actor} close failed for session {SessionId}.",
|
"Dashboard admin {Actor} close failed for session {SessionId}.",
|
||||||
actor,
|
actor,
|
||||||
sessionId);
|
sessionId);
|
||||||
|
await WriteAuditAsync(user, CloseSessionAction, sessionId, AuditOutcome.Failure, exception.Message, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
return DashboardSessionAdminResult.Fail(
|
return DashboardSessionAdminResult.Fail(
|
||||||
$"Close failed: {exception.Message}");
|
$"Close failed: {exception.Message}");
|
||||||
}
|
}
|
||||||
@@ -98,6 +129,8 @@ public sealed class DashboardSessionAdminService(
|
|||||||
"Dashboard admin {Actor} close failed unexpectedly for session {SessionId}.",
|
"Dashboard admin {Actor} close failed unexpectedly for session {SessionId}.",
|
||||||
actor,
|
actor,
|
||||||
sessionId);
|
sessionId);
|
||||||
|
await WriteAuditAsync(user, CloseSessionAction, sessionId, AuditOutcome.Failure, "unexpected", cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
return DashboardSessionAdminResult.Fail(
|
return DashboardSessionAdminResult.Fail(
|
||||||
$"Close failed unexpectedly for session {sessionId}. See the gateway log for details.");
|
$"Close failed unexpectedly for session {sessionId}. See the gateway log for details.");
|
||||||
}
|
}
|
||||||
@@ -111,6 +144,8 @@ public sealed class DashboardSessionAdminService(
|
|||||||
{
|
{
|
||||||
if (!CanManage(user))
|
if (!CanManage(user))
|
||||||
{
|
{
|
||||||
|
await WriteAuditAsync(user, KillWorkerAction, sessionId, AuditOutcome.Denied, "unauthorized", cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
return DashboardSessionAdminResult.Fail(UnauthorizedMessage);
|
return DashboardSessionAdminResult.Fail(UnauthorizedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +168,15 @@ public sealed class DashboardSessionAdminService(
|
|||||||
ResolveRemoteAddress(),
|
ResolveRemoteAddress(),
|
||||||
result.AlreadyClosed);
|
result.AlreadyClosed);
|
||||||
|
|
||||||
|
await WriteAuditAsync(
|
||||||
|
user,
|
||||||
|
KillWorkerAction,
|
||||||
|
sessionId,
|
||||||
|
AuditOutcome.Success,
|
||||||
|
result.AlreadyClosed ? "alreadyClosed" : "killed",
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return DashboardSessionAdminResult.Success(
|
return DashboardSessionAdminResult.Success(
|
||||||
result.AlreadyClosed
|
result.AlreadyClosed
|
||||||
? $"Session {sessionId} was already closed."
|
? $"Session {sessionId} was already closed."
|
||||||
@@ -140,6 +184,8 @@ public sealed class DashboardSessionAdminService(
|
|||||||
}
|
}
|
||||||
catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound)
|
catch (SessionManagerException exception) when (exception.ErrorCode == SessionManagerErrorCode.SessionNotFound)
|
||||||
{
|
{
|
||||||
|
await WriteAuditAsync(user, KillWorkerAction, sessionId, AuditOutcome.Failure, "not-found", cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found.");
|
return DashboardSessionAdminResult.Fail($"Session {sessionId} was not found.");
|
||||||
}
|
}
|
||||||
catch (SessionManagerException exception)
|
catch (SessionManagerException exception)
|
||||||
@@ -149,6 +195,8 @@ public sealed class DashboardSessionAdminService(
|
|||||||
"Dashboard admin {Actor} kill failed for session {SessionId}.",
|
"Dashboard admin {Actor} kill failed for session {SessionId}.",
|
||||||
actor,
|
actor,
|
||||||
sessionId);
|
sessionId);
|
||||||
|
await WriteAuditAsync(user, KillWorkerAction, sessionId, AuditOutcome.Failure, exception.Message, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
return DashboardSessionAdminResult.Fail(
|
return DashboardSessionAdminResult.Fail(
|
||||||
$"Kill failed: {exception.Message}");
|
$"Kill failed: {exception.Message}");
|
||||||
}
|
}
|
||||||
@@ -163,6 +211,8 @@ public sealed class DashboardSessionAdminService(
|
|||||||
"Dashboard admin {Actor} kill failed unexpectedly for session {SessionId}.",
|
"Dashboard admin {Actor} kill failed unexpectedly for session {SessionId}.",
|
||||||
actor,
|
actor,
|
||||||
sessionId);
|
sessionId);
|
||||||
|
await WriteAuditAsync(user, KillWorkerAction, sessionId, AuditOutcome.Failure, "unexpected", cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
return DashboardSessionAdminResult.Fail(
|
return DashboardSessionAdminResult.Fail(
|
||||||
$"Kill failed unexpectedly for session {sessionId}. See the gateway log for details.");
|
$"Kill failed unexpectedly for session {sessionId}. See the gateway log for details.");
|
||||||
}
|
}
|
||||||
@@ -177,4 +227,51 @@ public sealed class DashboardSessionAdminService(
|
|||||||
{
|
{
|
||||||
return httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
return httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a canonical <see cref="AuditEvent"/> for a dashboard session-admin action through the
|
||||||
|
/// best-effort <see cref="IAuditWriter"/> (failures are swallowed/logged by the writer, so this
|
||||||
|
/// never throws and never masks the operation result). <see cref="AuditEvent.Actor"/> is the LDAP
|
||||||
|
/// operator, <see cref="AuditEvent.Target"/> the session id, and <paramref name="detail"/> is wrapped
|
||||||
|
/// as the <c>detail</c> field of the JSON extension bag.
|
||||||
|
/// </summary>
|
||||||
|
private async Task WriteAuditAsync(
|
||||||
|
ClaimsPrincipal user,
|
||||||
|
string action,
|
||||||
|
string sessionId,
|
||||||
|
AuditOutcome outcome,
|
||||||
|
string? detail,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
AuditEvent auditEvent = new()
|
||||||
|
{
|
||||||
|
EventId = Guid.NewGuid(),
|
||||||
|
OccurredAtUtc = DateTimeOffset.UtcNow,
|
||||||
|
Actor = ResolveActor(user),
|
||||||
|
Action = action,
|
||||||
|
Outcome = outcome,
|
||||||
|
Category = SessionAdminCategory,
|
||||||
|
Target = sessionId,
|
||||||
|
SourceNode = ResolveRemoteAddress(),
|
||||||
|
CorrelationId = ParseCorrelationId(),
|
||||||
|
DetailsJson = WrapDetail(detail),
|
||||||
|
};
|
||||||
|
|
||||||
|
await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives a correlation id from the request trace identifier when it is a well-formed GUID;
|
||||||
|
/// otherwise null (the default <c>HttpContext.TraceIdentifier</c> is the connection:request form,
|
||||||
|
/// not a GUID, so it correlates to null rather than fabricating one).
|
||||||
|
/// </summary>
|
||||||
|
private Guid? ParseCorrelationId() =>
|
||||||
|
Guid.TryParse(httpContextAccessor.HttpContext?.TraceIdentifier, out Guid correlationId)
|
||||||
|
? correlationId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private static string? WrapDetail(string? detail) =>
|
||||||
|
detail is null
|
||||||
|
? null
|
||||||
|
: JsonSerializer.Serialize(new Dictionary<string, string> { ["detail"] = detail });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,7 +351,11 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
_heartbeatFailures++;
|
_heartbeatFailures++;
|
||||||
}
|
}
|
||||||
|
|
||||||
_heartbeatFailuresCounter.Add(1, new KeyValuePair<string, object?>("session_id", sessionId));
|
// Exported without a session_id tag: per-session attribution is unbounded cardinality
|
||||||
|
// (every session ever opened mints a new exporter time series, bloating Prometheus/OTLP
|
||||||
|
// storage on long-running gateways). Per-session detail stays available via the dashboard
|
||||||
|
// snapshot and the log scope; the exported counter tracks only the aggregate.
|
||||||
|
_heartbeatFailuresCounter.Add(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+52
-1
@@ -128,11 +128,58 @@ public sealed class DashboardAuthorizationHandlerTests
|
|||||||
Assert.False(context.HasSucceeded);
|
Assert.False(context.HasSucceeded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that the anonymous-localhost bypass is read-only: a loopback request with
|
||||||
|
/// <c>AllowAnonymousLocalhost</c> on is denied the <see cref="DashboardAuthorizationRequirement.AdminOnly"/>
|
||||||
|
/// requirement, so destructive/admin surfaces are never reachable without a real Admin role.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_AnonymousLocalhostAllowed_DoesNotSatisfyAdminPolicy()
|
||||||
|
{
|
||||||
|
AuthorizationHandlerContext context = await AuthorizeAsync(
|
||||||
|
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||||
|
IPAddress.Loopback,
|
||||||
|
allowAnonymousLocalhost: true,
|
||||||
|
DashboardAuthorizationRequirement.AdminOnly);
|
||||||
|
|
||||||
|
Assert.False(context.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that with authentication fully disabled the environment bypass stays read-only:
|
||||||
|
/// a remote request satisfies <see cref="DashboardAuthorizationRequirement.AnyDashboardRole"/>
|
||||||
|
/// (Viewer) but is denied <see cref="DashboardAuthorizationRequirement.AdminOnly"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_AuthenticationDisabled_GrantsViewerButNotAdmin()
|
||||||
|
{
|
||||||
|
AuthorizationHandlerContext viewerContext = await AuthorizeAsync(
|
||||||
|
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||||
|
IPAddress.Parse("10.0.0.5"),
|
||||||
|
allowAnonymousLocalhost: false,
|
||||||
|
DashboardAuthorizationRequirement.AnyDashboardRole,
|
||||||
|
authenticationMode: AuthenticationMode.Disabled);
|
||||||
|
|
||||||
|
Assert.True(viewerContext.HasSucceeded);
|
||||||
|
|
||||||
|
AuthorizationHandlerContext adminContext = await AuthorizeAsync(
|
||||||
|
new ClaimsPrincipal(new ClaimsIdentity()),
|
||||||
|
IPAddress.Parse("10.0.0.5"),
|
||||||
|
allowAnonymousLocalhost: false,
|
||||||
|
DashboardAuthorizationRequirement.AdminOnly,
|
||||||
|
authenticationMode: AuthenticationMode.Disabled);
|
||||||
|
|
||||||
|
Assert.False(adminContext.HasSucceeded);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<AuthorizationHandlerContext> AuthorizeAsync(
|
private static async Task<AuthorizationHandlerContext> AuthorizeAsync(
|
||||||
ClaimsPrincipal principal,
|
ClaimsPrincipal principal,
|
||||||
IPAddress remoteAddress,
|
IPAddress remoteAddress,
|
||||||
bool allowAnonymousLocalhost,
|
bool allowAnonymousLocalhost,
|
||||||
DashboardAuthorizationRequirement requirement)
|
DashboardAuthorizationRequirement requirement,
|
||||||
|
AuthenticationMode authenticationMode = AuthenticationMode.ApiKey)
|
||||||
{
|
{
|
||||||
DefaultHttpContext httpContext = new();
|
DefaultHttpContext httpContext = new();
|
||||||
httpContext.Connection.RemoteIpAddress = remoteAddress;
|
httpContext.Connection.RemoteIpAddress = remoteAddress;
|
||||||
@@ -140,6 +187,10 @@ public sealed class DashboardAuthorizationHandlerTests
|
|||||||
new HttpContextAccessor { HttpContext = httpContext },
|
new HttpContextAccessor { HttpContext = httpContext },
|
||||||
Options.Create(new GatewayOptions
|
Options.Create(new GatewayOptions
|
||||||
{
|
{
|
||||||
|
Authentication = new AuthenticationOptions
|
||||||
|
{
|
||||||
|
Mode = authenticationMode,
|
||||||
|
},
|
||||||
Dashboard = new DashboardOptions
|
Dashboard = new DashboardOptions
|
||||||
{
|
{
|
||||||
AllowAnonymousLocalhost = allowAnonymousLocalhost,
|
AllowAnonymousLocalhost = allowAnonymousLocalhost,
|
||||||
|
|||||||
+96
-2
@@ -1,6 +1,8 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
@@ -198,14 +200,91 @@ public sealed class DashboardSessionAdminServiceTests
|
|||||||
Assert.False(string.IsNullOrWhiteSpace(result.Message));
|
Assert.False(string.IsNullOrWhiteSpace(result.Message));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DashboardSessionAdminService CreateService(ISessionManager sessionManager)
|
/// <summary>
|
||||||
|
/// Verifies that a successful close writes a canonical <c>dashboard-close-session</c>
|
||||||
|
/// <see cref="AuditEvent"/> — actor, session-id target, and Success outcome — to the
|
||||||
|
/// audit store, not only the operational log (SEC-12).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task CloseSessionAsync_AdminClose_WritesCanonicalAuditEvent()
|
||||||
|
{
|
||||||
|
FakeSessionManager sessionManager = new();
|
||||||
|
RecordingAuditWriter auditWriter = new();
|
||||||
|
DashboardSessionAdminService service = CreateService(sessionManager, auditWriter);
|
||||||
|
|
||||||
|
DashboardSessionAdminResult result = await service.CloseSessionAsync(
|
||||||
|
CreateUser(DashboardRoles.Admin),
|
||||||
|
"session-1",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
AuditEvent audit = Assert.Single(auditWriter.Events);
|
||||||
|
Assert.Equal(DashboardSessionAdminService.CloseSessionAction, audit.Action);
|
||||||
|
Assert.Equal(DashboardSessionAdminService.SessionAdminCategory, audit.Category);
|
||||||
|
Assert.Equal("session-1", audit.Target);
|
||||||
|
Assert.Equal("tester", audit.Actor);
|
||||||
|
Assert.Equal(AuditOutcome.Success, audit.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that a successful kill writes a canonical <c>dashboard-kill-worker</c>
|
||||||
|
/// <see cref="AuditEvent"/> to the audit store (SEC-12).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task KillWorkerAsync_AdminKill_WritesCanonicalAuditEvent()
|
||||||
|
{
|
||||||
|
FakeSessionManager sessionManager = new();
|
||||||
|
RecordingAuditWriter auditWriter = new();
|
||||||
|
DashboardSessionAdminService service = CreateService(sessionManager, auditWriter);
|
||||||
|
|
||||||
|
DashboardSessionAdminResult result = await service.KillWorkerAsync(
|
||||||
|
CreateUser(DashboardRoles.Admin),
|
||||||
|
"session-1",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
AuditEvent audit = Assert.Single(auditWriter.Events);
|
||||||
|
Assert.Equal(DashboardSessionAdminService.KillWorkerAction, audit.Action);
|
||||||
|
Assert.Equal("session-1", audit.Target);
|
||||||
|
Assert.Equal(AuditOutcome.Success, audit.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that an unauthorized (viewer) close attempt still writes a <c>Denied</c>
|
||||||
|
/// audit row, so rejected destructive attempts are durably recorded (SEC-12).
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task CloseSessionAsync_ViewerDenied_WritesDeniedAuditEvent()
|
||||||
|
{
|
||||||
|
FakeSessionManager sessionManager = new();
|
||||||
|
RecordingAuditWriter auditWriter = new();
|
||||||
|
DashboardSessionAdminService service = CreateService(sessionManager, auditWriter);
|
||||||
|
|
||||||
|
DashboardSessionAdminResult result = await service.CloseSessionAsync(
|
||||||
|
CreateUser(DashboardRoles.Viewer),
|
||||||
|
"session-1",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Succeeded);
|
||||||
|
AuditEvent audit = Assert.Single(auditWriter.Events);
|
||||||
|
Assert.Equal(DashboardSessionAdminService.CloseSessionAction, audit.Action);
|
||||||
|
Assert.Equal(AuditOutcome.Denied, audit.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardSessionAdminService CreateService(
|
||||||
|
ISessionManager sessionManager,
|
||||||
|
IAuditWriter? auditWriter = null)
|
||||||
{
|
{
|
||||||
DefaultHttpContext httpContext = new();
|
DefaultHttpContext httpContext = new();
|
||||||
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
|
||||||
|
|
||||||
return new DashboardSessionAdminService(
|
return new DashboardSessionAdminService(
|
||||||
sessionManager,
|
sessionManager,
|
||||||
new HttpContextAccessor { HttpContext = httpContext });
|
new HttpContextAccessor { HttpContext = httpContext },
|
||||||
|
auditWriter ?? new RecordingAuditWriter());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ClaimsPrincipal CreateUser(string role)
|
private static ClaimsPrincipal CreateUser(string role)
|
||||||
@@ -219,6 +298,21 @@ public sealed class DashboardSessionAdminServiceTests
|
|||||||
return new ClaimsPrincipal(identity);
|
return new ClaimsPrincipal(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingAuditWriter : IAuditWriter
|
||||||
|
{
|
||||||
|
private readonly ConcurrentQueue<AuditEvent> _events = new();
|
||||||
|
|
||||||
|
/// <summary>Gets the audit events written through this writer, in order.</summary>
|
||||||
|
public IReadOnlyList<AuditEvent> Events => _events.ToArray();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
_events.Enqueue(evt);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeSessionManager : ISessionManager
|
private sealed class FakeSessionManager : ISessionManager
|
||||||
{
|
{
|
||||||
/// <summary>Gets the number of times CloseSessionAsync was invoked.</summary>
|
/// <summary>Gets the number of times CloseSessionAsync was invoked.</summary>
|
||||||
|
|||||||
@@ -157,6 +157,56 @@ public sealed class GatewayMetricsTests
|
|||||||
Assert.Equal(2, capturedMode);
|
Assert.Equal(2, capturedMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that <see cref="GatewayMetrics.HeartbeatFailed"/> increments
|
||||||
|
/// <c>mxgateway.heartbeats.failed</c> without emitting a <c>session_id</c> tag: the tag is
|
||||||
|
/// unbounded cardinality (every session mints a new exporter time series), so per-session
|
||||||
|
/// attribution is deliberately kept out of the exported counter (SEC-20).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void HeartbeatFailed_IncrementsCounterWithoutSessionIdTag()
|
||||||
|
{
|
||||||
|
using GatewayMetrics metrics = new();
|
||||||
|
using MeterListener listener = new();
|
||||||
|
|
||||||
|
long capturedValue = 0;
|
||||||
|
bool sawSessionIdTag = false;
|
||||||
|
|
||||||
|
listener.InstrumentPublished = (instrument, meterListener) =>
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(instrument.Meter, metrics.Meter)
|
||||||
|
&& instrument.Name == "mxgateway.heartbeats.failed")
|
||||||
|
{
|
||||||
|
meterListener.EnableMeasurementEvents(instrument);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
listener.SetMeasurementEventCallback<long>(
|
||||||
|
(instrument, measurement, tags, _) =>
|
||||||
|
{
|
||||||
|
if (!ReferenceEquals(instrument.Meter, metrics.Meter)
|
||||||
|
|| instrument.Name != "mxgateway.heartbeats.failed")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
capturedValue += measurement;
|
||||||
|
foreach (KeyValuePair<string, object?> tag in tags)
|
||||||
|
{
|
||||||
|
if (tag.Key == "session_id")
|
||||||
|
{
|
||||||
|
sawSessionIdTag = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
listener.Start();
|
||||||
|
|
||||||
|
metrics.HeartbeatFailed("session-1");
|
||||||
|
|
||||||
|
Assert.Equal(1, capturedValue);
|
||||||
|
Assert.False(sawSessionIdTag);
|
||||||
|
Assert.Equal(1, metrics.GetSnapshot().HeartbeatFailures);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that removing session events only affects that session.</summary>
|
/// <summary>Verifies that removing session events only affects that session.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RemoveSessionEvents_RemovesOnlyThatSession()
|
public void RemoveSessionEvents_RemovesOnlyThatSession()
|
||||||
|
|||||||
Reference in New Issue
Block a user