feat(audit): persist OPC UA certificate-trust decisions to the central audit log (ScadaBridge#43)
New AuditChannel.Security with AuditKind.CertTrust / CertTrustRemove (appended, ordinals stable). Both central entry points - the management actor handlers and the Central UI CertManagementService - write a best-effort ICentralAuditWriter row after the site outcome is known (Delivered when all reachable site nodes acked, Failed otherwise, incl. relay timeouts), naming actor, site, thumbprint and connection. Shared builder: CertTrustAuditEvents. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -93,7 +93,7 @@ row per lifecycle event across all channels.
|
|||||||
| `EventId` | `uniqueidentifier` PK | Generated where the event originates (site or central). Idempotency key. |
|
| `EventId` | `uniqueidentifier` PK | Generated where the event originates (site or central). Idempotency key. |
|
||||||
| `OccurredAtUtc` | `datetime2` | When the event happened (call returned, retry attempted, etc.). |
|
| `OccurredAtUtc` | `datetime2` | When the event happened (call returned, retry attempted, etc.). |
|
||||||
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
|
| `IngestedAtUtc` | `datetime2` | When central persisted the row (lags `OccurredAtUtc` for site-originated rows). |
|
||||||
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound` \| `SecuredWrite`. |
|
| `Channel` | `varchar(32)` | `ApiOutbound` \| `DbOutbound` \| `Notification` \| `ApiInbound` \| `SecuredWrite` \| `Cluster` \| `ScriptTrace` \| `Security`. |
|
||||||
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
|
| `Kind` | `varchar(32)` | Event kind discriminator (see kinds list below). |
|
||||||
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
|
| `CorrelationId` | `uniqueidentifier` NULL | Ties multi-event operations together. `TrackedOperationId` for cached calls, `NotificationId` for notifications, request-id for inbound API. NULL for sync one-shot calls. |
|
||||||
| `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. |
|
| `ExecutionId` | `uniqueidentifier` NULL | The originating script execution / inbound request — the universal per-run correlation value; distinct from `CorrelationId`, which is the per-operation lifecycle id. Stamped on *every* audit row emitted by one execution. |
|
||||||
@@ -144,6 +144,8 @@ row per lifecycle event across all channels.
|
|||||||
| `SecuredWriteApprove` | A verifier wins the approval CAS for a pending secured write. |
|
| `SecuredWriteApprove` | A verifier wins the approval CAS for a pending secured write. |
|
||||||
| `SecuredWriteReject` | A verifier rejects a pending secured write (`Status=Discarded`). |
|
| `SecuredWriteReject` | A verifier rejects a pending secured write (`Status=Discarded`). |
|
||||||
| `SecuredWriteExecute` | The approved write was relayed to the site MxGateway — terminal outcome (`Delivered`-equivalent on success, `Failed` on error). |
|
| `SecuredWriteExecute` | The approved write was relayed to the site MxGateway — terminal outcome (`Delivered`-equivalent on success, `Failed` on error). |
|
||||||
|
| `CertTrust` | Central-direct row on the `Security` channel (Gitea #43): an administrator trusted an OPC UA server certificate at a site. Written by both central entry points (management actor, Central UI `CertManagementService`) after the site outcome is known — `Delivered` when every reachable site node acked, `Failed` otherwise. `Target` = `<site>/<thumbprint>`; `Extra` carries thumbprint, site and connection name. |
|
||||||
|
| `CertTrustRemove` | As `CertTrust`, for removing a trusted certificate. |
|
||||||
| `ReconciliationAbandoned` | Central-direct synthetic row (`Status=Failed`) written when a reconciliation pull row failed to insert on every retry up to the permanent-abandon threshold and central advanced its cursor past it. `Extra` carries the abandoned `EventId`, the source site, and the final error — so the permanent loss is queryable, not only in the Critical log line. |
|
| `ReconciliationAbandoned` | Central-direct synthetic row (`Status=Failed`) written when a reconciliation pull row failed to insert on every retry up to the permanent-abandon threshold and central advanced its cursor past it. `Extra` carries the abandoned `EventId`, the source site, and the final error — so the permanent loss is queryable, not only in the Critical log line. |
|
||||||
|
|
||||||
Inbound API is intentionally collapsed to a single `InboundRequest` (or
|
Inbound API is intentionally collapsed to a single `InboundRequest` (or
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||||
using Microsoft.AspNetCore.Components.Authorization;
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||||
using ZB.MOM.WW.ScadaBridge.Security;
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
|
|
||||||
@@ -26,14 +29,24 @@ public sealed class CertManagementService : ICertManagementService
|
|||||||
{
|
{
|
||||||
private readonly CommunicationService _communication;
|
private readonly CommunicationService _communication;
|
||||||
private readonly AuthenticationStateProvider _auth;
|
private readonly AuthenticationStateProvider _auth;
|
||||||
|
private readonly ICentralAuditWriter? _audit;
|
||||||
|
private readonly ILogger<CertManagementService>? _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="CertManagementService"/>.
|
/// Initializes a new instance of the <see cref="CertManagementService"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="communication">Central-side cluster communication service.</param>
|
/// <param name="communication">Central-side cluster communication service.</param>
|
||||||
/// <param name="auth">Authentication state provider used for the role guards.</param>
|
/// <param name="auth">Authentication state provider used for the role guards.</param>
|
||||||
public CertManagementService(CommunicationService communication, AuthenticationStateProvider auth)
|
/// <param name="audit">Central audit writer for trust-decision rows (Gitea #43); optional so hosts without one still work.</param>
|
||||||
|
/// <param name="logger">Logger for best-effort audit failures.</param>
|
||||||
|
public CertManagementService(
|
||||||
|
CommunicationService communication,
|
||||||
|
AuthenticationStateProvider auth,
|
||||||
|
ICentralAuditWriter? audit = null,
|
||||||
|
ILogger<CertManagementService>? logger = null)
|
||||||
{
|
{
|
||||||
|
_audit = audit;
|
||||||
|
_logger = logger;
|
||||||
_communication = communication ?? throw new ArgumentNullException(nameof(communication));
|
_communication = communication ?? throw new ArgumentNullException(nameof(communication));
|
||||||
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
|
_auth = auth ?? throw new ArgumentNullException(nameof(auth));
|
||||||
}
|
}
|
||||||
@@ -54,17 +67,14 @@ public sealed class CertManagementService : ICertManagementService
|
|||||||
return new CertTrustResult(false, "Not authorized.", null);
|
return new CertTrustResult(false, "Not authorized.", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CertTrustResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _communication.TrustServerCertAsync(
|
result = await _communication.TrustServerCertAsync(
|
||||||
siteIdentifier,
|
siteIdentifier,
|
||||||
new TrustServerCertCommand(connectionName, derBase64, thumbprint),
|
new TrustServerCertCommand(connectionName, derBase64, thumbprint),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
catch (TimeoutException ex)
|
|
||||||
{
|
|
||||||
return new CertTrustResult(false, ex.Message, null);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
// Caller-initiated cancel — propagate so Blazor can drop the response
|
// Caller-initiated cancel — propagate so Blazor can drop the response
|
||||||
@@ -73,8 +83,12 @@ public sealed class CertManagementService : ICertManagementService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return new CertTrustResult(false, ex.Message, null);
|
// Includes TimeoutException: collapse into a non-success result.
|
||||||
|
result = new CertTrustResult(false, ex.Message, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await AuditAsync(remove: false, siteIdentifier, thumbprint, connectionName, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -122,22 +136,44 @@ public sealed class CertManagementService : ICertManagementService
|
|||||||
return new CertTrustResult(false, "Not authorized.", null);
|
return new CertTrustResult(false, "Not authorized.", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CertTrustResult result;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await _communication.RemoveServerCertAsync(
|
result = await _communication.RemoveServerCertAsync(
|
||||||
siteIdentifier, new RemoveServerCertCommand(thumbprint), cancellationToken);
|
siteIdentifier, new RemoveServerCertCommand(thumbprint), cancellationToken);
|
||||||
}
|
}
|
||||||
catch (TimeoutException ex)
|
|
||||||
{
|
|
||||||
return new CertTrustResult(false, ex.Message, null);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return new CertTrustResult(false, ex.Message, null);
|
result = new CertTrustResult(false, ex.Message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await AuditAsync(remove: true, siteIdentifier, thumbprint, connectionName: null, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records the trust decision in the central AuditLog (Gitea #43) once its outcome is known.
|
||||||
|
/// Best-effort: never alters the result returned to the page.
|
||||||
|
/// </summary>
|
||||||
|
private async Task AuditAsync(bool remove, string siteIdentifier, string thumbprint, string? connectionName, CertTrustResult result)
|
||||||
|
{
|
||||||
|
if (_audit is null) return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var state = await _auth.GetAuthoritativeStateAsync();
|
||||||
|
var evt = CertTrustAuditEvents.Create(
|
||||||
|
remove, state.User.GetUsername(), siteIdentifier, thumbprint, connectionName, result);
|
||||||
|
await _audit.WriteAsync(evt);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger?.LogWarning(ex,
|
||||||
|
"Best-effort cert-trust audit emission failed (site={Site}, thumbprint={Thumbprint}); the trust decision itself is unaffected.",
|
||||||
|
siteIdentifier, thumbprint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|||||||
// singleton (active node only). For trust/remove it broadcasts the
|
// singleton (active node only). For trust/remove it broadcasts the
|
||||||
// corresponding per-node internal message to the per-node CertStoreActor on
|
// corresponding per-node internal message to the per-node CertStoreActor on
|
||||||
// EVERY site cluster node; for list it answers from the local node.
|
// EVERY site cluster node; for list it answers from the local node.
|
||||||
|
//
|
||||||
|
// The decision itself IS recorded centrally (Gitea #43): both central entry points (the
|
||||||
|
// management actor and the Central UI CertManagementService) write an AuditChannel.Security
|
||||||
|
// AuditLog row (AuditKind.CertTrust / CertTrustRemove) naming the actor, site and thumbprint,
|
||||||
|
// with Delivered/Failed per the aggregate site outcome. Only the certificate itself stays site-local.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the central <see cref="AuditEvent"/> recording an OPC UA server-certificate trust
|
||||||
|
/// decision (Gitea #43). The trusted-peer PKI store is site-local and each decision is already
|
||||||
|
/// broadcast to both site nodes; this is the central, queryable record of WHO made the decision.
|
||||||
|
/// Shared by the two central entry points (the management actor and the Central UI service) so
|
||||||
|
/// both emit the identical row shape.
|
||||||
|
/// </summary>
|
||||||
|
public static class CertTrustAuditEvents
|
||||||
|
{
|
||||||
|
/// <summary>Builds the audit row for a trust or remove decision after its outcome is known.</summary>
|
||||||
|
/// <param name="remove">True for a trust removal, false for a trust grant.</param>
|
||||||
|
/// <param name="actor">The authenticated user who made the decision.</param>
|
||||||
|
/// <param name="siteIdentifier">The site whose PKI stores were targeted.</param>
|
||||||
|
/// <param name="thumbprint">The certificate thumbprint.</param>
|
||||||
|
/// <param name="connectionName">The data connection the certificate was captured from (trust only).</param>
|
||||||
|
/// <param name="result">The aggregate site outcome, or null when the relay threw before an outcome.</param>
|
||||||
|
/// <param name="exceptionMessage">The relay failure message when <paramref name="result"/> is null.</param>
|
||||||
|
/// <returns>The audit event, ready for <c>ICentralAuditWriter.WriteAsync</c>.</returns>
|
||||||
|
public static AuditEvent Create(
|
||||||
|
bool remove,
|
||||||
|
string actor,
|
||||||
|
string siteIdentifier,
|
||||||
|
string thumbprint,
|
||||||
|
string? connectionName,
|
||||||
|
CertTrustResult? result,
|
||||||
|
string? exceptionMessage = null)
|
||||||
|
{
|
||||||
|
var succeeded = result?.Success == true;
|
||||||
|
return ScadaBridgeAuditEventFactory.Create(
|
||||||
|
channel: AuditChannel.Security,
|
||||||
|
kind: remove ? AuditKind.CertTrustRemove : AuditKind.CertTrust,
|
||||||
|
status: succeeded ? AuditStatus.Delivered : AuditStatus.Failed,
|
||||||
|
actor: actor,
|
||||||
|
target: $"{siteIdentifier}/{thumbprint}",
|
||||||
|
sourceSiteId: siteIdentifier,
|
||||||
|
errorMessage: succeeded ? null : (result?.Error ?? exceptionMessage ?? "Unknown error."),
|
||||||
|
extra: JsonSerializer.Serialize(new { thumbprint, siteIdentifier, connectionName }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,5 +29,12 @@ public enum AuditChannel
|
|||||||
/// (plus the always-on <see cref="AuditKind.RouteCall"/>). Appended last so stored
|
/// (plus the always-on <see cref="AuditKind.RouteCall"/>). Appended last so stored
|
||||||
/// ordinals of existing channels never move.
|
/// ordinals of existing channels never move.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
ScriptTrace
|
ScriptTrace,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Operator-initiated security-trust decisions made through the central management surface
|
||||||
|
/// (UI, CLI, HTTP Management API) — currently OPC UA server-certificate trust and removal.
|
||||||
|
/// Appended last so stored ordinals of existing channels never move. (Gitea #43)
|
||||||
|
/// </summary>
|
||||||
|
Security
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,5 +64,19 @@ public enum AuditKind
|
|||||||
AttributeWait,
|
AttributeWait,
|
||||||
|
|
||||||
/// <summary>One log line a traced script (or the runtime on its behalf) wrote; folded into its owning step by the Flow Debugger, never a node (design 2026-09-09 D1).</summary>
|
/// <summary>One log line a traced script (or the runtime on its behalf) wrote; folded into its owning step by the Flow Debugger, never a node (design 2026-09-09 D1).</summary>
|
||||||
ScriptLog
|
ScriptLog,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An administrator trusted an OPC UA server certificate for a site's data connection
|
||||||
|
/// (<see cref="AuditChannel.Security"/>). One row per decision, written after the site
|
||||||
|
/// reports the outcome: <see cref="AuditStatus.Delivered"/> when every reachable site node
|
||||||
|
/// acked, <see cref="AuditStatus.Failed"/> otherwise (Gitea #43).
|
||||||
|
/// </summary>
|
||||||
|
CertTrust,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An administrator removed trust for an OPC UA server certificate at a site
|
||||||
|
/// (<see cref="AuditChannel.Security"/>); same status semantics as <see cref="CertTrust"/> (Gitea #43).
|
||||||
|
/// </summary>
|
||||||
|
CertTrustRemove
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4095,7 +4095,8 @@ public class ManagementActor : ReceiveActor
|
|||||||
?? throw new ManagementCommandException("SiteIdentifier is required when trusting a server certificate via the management API.");
|
?? throw new ManagementCommandException("SiteIdentifier is required when trusting a server certificate via the management API.");
|
||||||
await EnforceSiteScopeForIdentifier(sp, user, site);
|
await EnforceSiteScopeForIdentifier(sp, user, site);
|
||||||
var commService = sp.GetRequiredService<CommunicationService>();
|
var commService = sp.GetRequiredService<CommunicationService>();
|
||||||
return await commService.TrustServerCertAsync(site, cmd);
|
return await RelayCertTrustAsync(sp, user, remove: false, site, cmd.Thumbprint, cmd.ConnectionName,
|
||||||
|
() => commService.TrustServerCertAsync(site, cmd));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object?> HandleRemoveServerCert(IServiceProvider sp, RemoveServerCertCommand cmd, AuthenticatedUser user)
|
private static async Task<object?> HandleRemoveServerCert(IServiceProvider sp, RemoveServerCertCommand cmd, AuthenticatedUser user)
|
||||||
@@ -4104,7 +4105,50 @@ public class ManagementActor : ReceiveActor
|
|||||||
?? throw new ManagementCommandException("SiteIdentifier is required when removing a server certificate via the management API.");
|
?? throw new ManagementCommandException("SiteIdentifier is required when removing a server certificate via the management API.");
|
||||||
await EnforceSiteScopeForIdentifier(sp, user, site);
|
await EnforceSiteScopeForIdentifier(sp, user, site);
|
||||||
var commService = sp.GetRequiredService<CommunicationService>();
|
var commService = sp.GetRequiredService<CommunicationService>();
|
||||||
return await commService.RemoveServerCertAsync(site, cmd);
|
return await RelayCertTrustAsync(sp, user, remove: true, site, cmd.Thumbprint, connectionName: null,
|
||||||
|
() => commService.RemoveServerCertAsync(site, cmd));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a cert trust/remove relay and records the decision in the central AuditLog
|
||||||
|
/// (Gitea #43). The row is written AFTER the outcome is known, with the real status; a relay
|
||||||
|
/// exception is audited as Failed and rethrown unchanged. Audit is best-effort and never alters
|
||||||
|
/// the relay's result.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<CertTrustResult> RelayCertTrustAsync(
|
||||||
|
IServiceProvider sp, AuthenticatedUser user, bool remove, string site, string thumbprint,
|
||||||
|
string? connectionName, Func<Task<CertTrustResult>> relay)
|
||||||
|
{
|
||||||
|
CertTrustResult result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await relay();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await WriteCertTrustAuditAsync(sp, user, remove, site, thumbprint, connectionName, null, ex.Message);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
await WriteCertTrustAuditAsync(sp, user, remove, site, thumbprint, connectionName, result, null);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteCertTrustAuditAsync(
|
||||||
|
IServiceProvider sp, AuthenticatedUser user, bool remove, string site, string thumbprint,
|
||||||
|
string? connectionName, CertTrustResult? result, string? exceptionMessage)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var evt = ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.CertTrustAuditEvents.Create(
|
||||||
|
remove, user.Username, site, thumbprint, connectionName, result, exceptionMessage);
|
||||||
|
await sp.GetRequiredService<ICentralAuditWriter>().WriteAsync(evt);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
sp.GetService<ILogger<ManagementActor>>()?.LogWarning(
|
||||||
|
ex, "Best-effort cert-trust audit emission failed (site={Site}, thumbprint={Thumbprint}); the trust decision itself is unaffected.",
|
||||||
|
site, thumbprint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object?> HandleListServerCerts(IServiceProvider sp, ListServerCertsCommand cmd, AuthenticatedUser user)
|
private static async Task<object?> HandleListServerCerts(IServiceProvider sp, ListServerCertsCommand cmd, AuthenticatedUser user)
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.Audit;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>Gitea #43: the Central UI cert-trust facade records every trust decision in the central audit log.</summary>
|
||||||
|
public class CertManagementServiceAuditTests
|
||||||
|
{
|
||||||
|
private readonly ICentralAuditWriter _audit = Substitute.For<ICentralAuditWriter>();
|
||||||
|
|
||||||
|
private sealed class FakeComm(CertTrustResult? result, Exception? throws = null)
|
||||||
|
: CommunicationService(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance)
|
||||||
|
{
|
||||||
|
public override Task<CertTrustResult> TrustServerCertAsync(string siteId, TrustServerCertCommand command, CancellationToken cancellationToken = default)
|
||||||
|
=> throws is null ? Task.FromResult(result!) : Task.FromException<CertTrustResult>(throws);
|
||||||
|
|
||||||
|
public override Task<CertTrustResult> RemoveServerCertAsync(string siteId, RemoveServerCertCommand command, CancellationToken cancellationToken = default)
|
||||||
|
=> throws is null ? Task.FromResult(result!) : Task.FromException<CertTrustResult>(throws);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AuthStub(ClaimsPrincipal user) : AuthenticationStateProvider
|
||||||
|
{
|
||||||
|
public override Task<AuthenticationState> GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimsPrincipal Admin() => new(new ClaimsIdentity(
|
||||||
|
[
|
||||||
|
new Claim(JwtTokenService.UsernameClaimType, "alice"),
|
||||||
|
new Claim(JwtTokenService.RoleClaimType, Roles.Administrator),
|
||||||
|
], "test"));
|
||||||
|
|
||||||
|
private CertManagementService Sut(CommunicationService comm, ClaimsPrincipal? user = null)
|
||||||
|
=> new(comm, new AuthStub(user ?? Admin()), _audit, NullLogger<CertManagementService>.Instance);
|
||||||
|
|
||||||
|
private AuditEvent Captured() => Assert.Single(_audit.ReceivedCalls()
|
||||||
|
.Where(c => c.GetMethodInfo().Name == nameof(ICentralAuditWriter.WriteAsync))
|
||||||
|
.Select(c => (AuditEvent)c.GetArguments()[0]!));
|
||||||
|
|
||||||
|
/// <summary>A successful trust writes a Delivered CertTrust row naming the actor, site and thumbprint.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Trust_Success_WritesDeliveredRow()
|
||||||
|
{
|
||||||
|
var r = await Sut(new FakeComm(new CertTrustResult(true, null, null))).TrustAsync("SITE1", "conn", "ZGVy", "AB12");
|
||||||
|
|
||||||
|
Assert.True(r.Success);
|
||||||
|
var evt = Captured();
|
||||||
|
Assert.Equal("alice", evt.Actor);
|
||||||
|
Assert.Equal("SITE1/AB12", evt.Target);
|
||||||
|
Assert.Contains("\"kind\":\"CertTrust\"", evt.DetailsJson);
|
||||||
|
Assert.Contains("\"status\":\"Delivered\"", evt.DetailsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A failed relay is audited as Failed with the error, and the page still gets its inline result.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Trust_RelayThrows_WritesFailedRow_AndReturnsInlineError()
|
||||||
|
{
|
||||||
|
var r = await Sut(new FakeComm(null, new TimeoutException("site unreachable"))).TrustAsync("SITE1", "conn", "ZGVy", "AB12");
|
||||||
|
|
||||||
|
Assert.False(r.Success);
|
||||||
|
var evt = Captured();
|
||||||
|
Assert.Contains("\"status\":\"Failed\"", evt.DetailsJson);
|
||||||
|
Assert.Contains("site unreachable", evt.DetailsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A removal writes a CertTrustRemove row.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Remove_Success_WritesRemoveRow()
|
||||||
|
{
|
||||||
|
await Sut(new FakeComm(new CertTrustResult(true, null, null))).RemoveAsync("SITE1", "AB12");
|
||||||
|
|
||||||
|
Assert.Contains("\"kind\":\"CertTrustRemove\"", Captured().DetailsJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An unauthorized caller never reaches the site and writes no audit row.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Trust_Unauthorized_WritesNothing()
|
||||||
|
{
|
||||||
|
var viewer = new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtTokenService.UsernameClaimType, "bob")], "test"));
|
||||||
|
|
||||||
|
var r = await Sut(new FakeComm(new CertTrustResult(true, null, null)), viewer).TrustAsync("SITE1", "conn", "ZGVy", "AB12");
|
||||||
|
|
||||||
|
Assert.False(r.Success);
|
||||||
|
Assert.Empty(_audit.ReceivedCalls());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An audit-writer failure never changes the trust result.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Trust_AuditWriterThrows_ResultUnaffected()
|
||||||
|
{
|
||||||
|
_audit.WriteAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>()).Returns(Task.FromException(new InvalidOperationException("db down")));
|
||||||
|
|
||||||
|
var r = await Sut(new FakeComm(new CertTrustResult(true, null, null))).TrustAsync("SITE1", "conn", "ZGVy", "AB12");
|
||||||
|
|
||||||
|
Assert.True(r.Success);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ public class AuditEnumTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void AuditChannel_HasExactlyExpectedMembers()
|
public void AuditChannel_HasExactlyExpectedMembers()
|
||||||
{
|
{
|
||||||
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound", "SecuredWrite", "Cluster", "ScriptTrace" };
|
var expected = new[] { "ApiOutbound", "DbOutbound", "Notification", "ApiInbound", "SecuredWrite", "Cluster", "ScriptTrace", "Security" };
|
||||||
var actual = Enum.GetValues(typeof(AuditChannel))
|
var actual = Enum.GetValues(typeof(AuditChannel))
|
||||||
.Cast<AuditChannel>()
|
.Cast<AuditChannel>()
|
||||||
.Select(x => x.ToString())
|
.Select(x => x.ToString())
|
||||||
@@ -23,9 +23,9 @@ public class AuditEnumTests
|
|||||||
Assert.Equal(expected, actual);
|
Assert.Equal(expected, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that audit kind has exactly twenty two expected members.</summary>
|
/// <summary>Verifies that audit kind has exactly twenty four expected members.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AuditKind_HasExactlyTwentyTwoExpectedMembers()
|
public void AuditKind_HasExactlyTwentyFourExpectedMembers()
|
||||||
{
|
{
|
||||||
var expected = new[]
|
var expected = new[]
|
||||||
{
|
{
|
||||||
@@ -37,13 +37,14 @@ public class AuditEnumTests
|
|||||||
"ReconciliationAbandoned",
|
"ReconciliationAbandoned",
|
||||||
"ManualFailover",
|
"ManualFailover",
|
||||||
"RouteCall", "ScriptRun", "AttributeWrite", "AttributeWait", "ScriptLog",
|
"RouteCall", "ScriptRun", "AttributeWrite", "AttributeWait", "ScriptLog",
|
||||||
|
"CertTrust", "CertTrustRemove",
|
||||||
};
|
};
|
||||||
var actual = Enum.GetValues(typeof(AuditKind))
|
var actual = Enum.GetValues(typeof(AuditKind))
|
||||||
.Cast<AuditKind>()
|
.Cast<AuditKind>()
|
||||||
.Select(x => x.ToString())
|
.Select(x => x.ToString())
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
Assert.Equal(22, actual.Length);
|
Assert.Equal(24, actual.Length);
|
||||||
Assert.Equal(expected, actual);
|
Assert.Equal(expected, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5621,6 +5621,46 @@ public class ManagementActorTests : TestKit, IDisposable
|
|||||||
Assert.Equal("SITE1", stub.LastTrustSite);
|
Assert.Equal("SITE1", stub.LastTrustSite);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Gitea #43: an admin trust decision is persisted to the central audit log with the real actor and outcome.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void TrustServerCert_Admin_WritesCentralAuditRow()
|
||||||
|
{
|
||||||
|
var writer = Substitute.For<ICentralAuditWriter>();
|
||||||
|
_services.AddSingleton(writer);
|
||||||
|
_services.AddSingleton<CommunicationService>(new StubCommunicationService());
|
||||||
|
var actor = CreateActor();
|
||||||
|
|
||||||
|
actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12", "SITE1"), "Administrator"));
|
||||||
|
|
||||||
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
var evt = Assert.Single(writer.ReceivedCalls()
|
||||||
|
.Where(c => c.GetMethodInfo().Name == nameof(ICentralAuditWriter.WriteAsync))
|
||||||
|
.Select(c => (ZB.MOM.WW.Audit.AuditEvent)c.GetArguments()[0]!));
|
||||||
|
Assert.Equal("Security", evt.Category);
|
||||||
|
Assert.Contains("\"kind\":\"CertTrust\"", evt.DetailsJson);
|
||||||
|
Assert.Contains("\"status\":\"Delivered\"", evt.DetailsJson);
|
||||||
|
Assert.Equal("SITE1/AB12", evt.Target);
|
||||||
|
Assert.False(string.IsNullOrEmpty(evt.Actor));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gitea #43: a removal is audited too.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void RemoveServerCert_Admin_WritesCentralAuditRow()
|
||||||
|
{
|
||||||
|
var writer = Substitute.For<ICentralAuditWriter>();
|
||||||
|
_services.AddSingleton(writer);
|
||||||
|
_services.AddSingleton<CommunicationService>(new StubCommunicationService());
|
||||||
|
var actor = CreateActor();
|
||||||
|
|
||||||
|
actor.Tell(Envelope(new RemoveServerCertCommand("AB12", "SITE1"), "Administrator"));
|
||||||
|
|
||||||
|
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||||
|
var evt = Assert.Single(writer.ReceivedCalls()
|
||||||
|
.Where(c => c.GetMethodInfo().Name == nameof(ICentralAuditWriter.WriteAsync))
|
||||||
|
.Select(c => (ZB.MOM.WW.Audit.AuditEvent)c.GetArguments()[0]!));
|
||||||
|
Assert.Contains("\"kind\":\"CertTrustRemove\"", evt.DetailsJson);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that remove server cert, when admin, relays to site.</summary>
|
/// <summary>Verifies that remove server cert, when admin, relays to site.</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RemoveServerCert_Admin_RelaysToSite()
|
public void RemoveServerCert_Admin_RelaysToSite()
|
||||||
|
|||||||
Reference in New Issue
Block a user