9a87d7a328
Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.
Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.
Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.
Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.
Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.
Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.
Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.
225 lines
11 KiB
C#
225 lines
11 KiB
C#
using Microsoft.AspNetCore.Components.Authorization;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
|
|
|
/// <summary>
|
|
/// Default <see cref="IAuditLogQueryService"/> implementation — a thin pass-through
|
|
/// to <see cref="IAuditLogRepository.QueryAsync"/>. Default page size is 100 (the
|
|
/// AuditResultsGrid default).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Each query opens its OWN DI scope and resolves a
|
|
/// fresh <see cref="IAuditLogRepository"/> — and therefore a fresh
|
|
/// <c>ScadaBridgeDbContext</c> — rather than sharing the scoped Blazor-circuit
|
|
/// context. Without this, the Audit Log page's query-string auto-load
|
|
/// (<c>/audit/log?correlationId=…</c>) races <c>AuditFilterBar.GetAllSitesAsync()</c>
|
|
/// on the single circuit-scoped <c>ScadaBridgeDbContext</c>, producing EF Core's
|
|
/// "A second operation was started on this context instance" error. Scope-per-query
|
|
/// removes the shared state so the two initial loads can run concurrently. This
|
|
/// mirrors the scope-per-message pattern used by <c>AuditLogIngestActor</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// A second constructor takes an <see cref="IAuditLogRepository"/> directly — a
|
|
/// test seam (mirroring <c>AuditLogIngestActor</c>'s dual ctor) so unit tests can
|
|
/// substitute a stub without standing up a DI container.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class AuditLogQueryService : IAuditLogQueryService
|
|
{
|
|
// Trailing window for the Health dashboard's Audit KPI tiles.
|
|
// Hard-coded here rather than configurable because the requirement
|
|
// (Component-AuditLog.md §"Health & KPIs") fixes "rows/min over the last hour"
|
|
// and "% errors over the last hour" as the KPI definition.
|
|
private static readonly TimeSpan KpiWindow = TimeSpan.FromHours(1);
|
|
|
|
// Audit Log Node filter: the distinct-source-nodes lookup powers
|
|
// the filter dropdown population. A 60s cache keeps rendering the filter
|
|
// bar cheap — node membership changes (failover, scaling) surface within
|
|
// a minute, which is acceptable for a filter affordance.
|
|
private static readonly TimeSpan DistinctSourceNodesTtl = TimeSpan.FromSeconds(60);
|
|
|
|
// Production path: open a fresh scope per operation. Null in the test-seam ctor.
|
|
private readonly IServiceScopeFactory? _scopeFactory;
|
|
private readonly AuthenticationStateProvider? _auth;
|
|
|
|
// Test seam: a directly-injected repository whose lifetime the test owns.
|
|
// Null in the production ctor.
|
|
private readonly IAuditLogRepository? _injectedRepository;
|
|
|
|
private readonly ICentralHealthAggregator _healthAggregator;
|
|
|
|
// Distinct-source-nodes cache. Lock guards the (snapshot, expiry) pair so
|
|
// a stampede of concurrent filter-bar renders does not turn into N DB hits.
|
|
private readonly object _sourceNodesLock = new();
|
|
private IReadOnlyList<string>? _cachedSourceNodes;
|
|
private DateTime _cachedSourceNodesExpiryUtc = DateTime.MinValue;
|
|
|
|
/// <summary>
|
|
/// Production constructor — resolves <see cref="IAuditLogRepository"/> from a
|
|
/// fresh DI scope on every call so each query gets its own
|
|
/// <c>ScadaBridgeDbContext</c> and never contends with the circuit-scoped
|
|
/// context the filter bar uses.
|
|
/// </summary>
|
|
/// <param name="scopeFactory">Factory used to open a fresh DI scope per query.</param>
|
|
/// <param name="healthAggregator">Central health aggregator for KPI backlog data.</param>
|
|
public AuditLogQueryService(
|
|
IServiceScopeFactory scopeFactory,
|
|
ICentralHealthAggregator healthAggregator, AuthenticationStateProvider? auth = null)
|
|
{
|
|
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
|
|
_auth = auth;
|
|
_healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Test-seam constructor — injects a repository instance whose lifetime the
|
|
/// caller owns. Used by unit tests that substitute a stub repository.
|
|
/// </summary>
|
|
/// <param name="repository">The audit log repository instance to use directly.</param>
|
|
/// <param name="healthAggregator">Central health aggregator for KPI backlog data.</param>
|
|
public AuditLogQueryService(
|
|
IAuditLogRepository repository,
|
|
ICentralHealthAggregator healthAggregator)
|
|
{
|
|
_injectedRepository = repository ?? throw new ArgumentNullException(nameof(repository));
|
|
_healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public int DefaultPageSize => 100;
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<AuditEventView>> QueryAsync(
|
|
AuditLogQueryFilter filter,
|
|
AuditLogPaging? paging = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(filter);
|
|
var effective = paging ?? new AuditLogPaging(DefaultPageSize);
|
|
|
|
// The repository seam returns canonical records; decompose
|
|
// each into a flat AuditEventView so the audit pages keep binding to typed
|
|
// properties.
|
|
// Test-seam ctor: use the injected repository directly.
|
|
if (_injectedRepository is not null)
|
|
{
|
|
var rows = await _injectedRepository.QueryAsync(filter, effective, ct);
|
|
return rows.Select(AuditEventView.From).ToList();
|
|
}
|
|
|
|
// Production: a fresh scope (and thus a fresh DbContext) per query so the
|
|
// page's auto-load never shares the circuit-scoped context.
|
|
await using var scope = _scopeFactory!.CreateAsyncScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
|
var result = await repository.QueryAsync(filter, effective, ct);
|
|
return result.Select(AuditEventView.From).ToList();
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default)
|
|
{
|
|
// 1. Volume + error counts: aggregate over the trailing 1h window.
|
|
// BacklogTotal is left at 0 by the repository — we fill it from the
|
|
// in-memory health aggregator below. Resolved via a per-call scope on
|
|
// the production path for the same context-isolation reason as
|
|
// QueryAsync; the test-seam ctor uses the injected repository.
|
|
AuditLogKpiSnapshot repoSnapshot;
|
|
if (_injectedRepository is not null)
|
|
{
|
|
repoSnapshot = await _injectedRepository.GetKpiSnapshotAsync(KpiWindow, nowUtc: null, ct);
|
|
}
|
|
else
|
|
{
|
|
await using var scope = _scopeFactory!.CreateAsyncScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
|
repoSnapshot = await repository.GetKpiSnapshotAsync(KpiWindow, nowUtc: null, ct);
|
|
}
|
|
|
|
// 2. Backlog: sum PendingCount across every site's latest report.
|
|
// Sites that have not yet reported or whose reporter is disabled
|
|
// leave SiteAuditBacklog null — those contribute zero (a Missing
|
|
// snapshot is "unknown", not "zero", but the tile is best-effort).
|
|
long backlog = 0;
|
|
foreach (var state in _healthAggregator.GetAllSiteStates().Values)
|
|
{
|
|
var pending = state.LatestReport?.SiteAuditBacklog?.PendingCount;
|
|
if (pending is > 0)
|
|
{
|
|
backlog += pending.Value;
|
|
}
|
|
}
|
|
|
|
return repoSnapshot with { BacklogTotal = backlog };
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
|
|
Guid executionId,
|
|
CancellationToken ct = default)
|
|
{
|
|
// Test-seam ctor: use the injected repository directly.
|
|
if (_injectedRepository is not null)
|
|
{
|
|
return await _injectedRepository.GetExecutionTreeAsync(executionId, ct);
|
|
}
|
|
|
|
// Production: a fresh scope (and thus a fresh DbContext) per call — the
|
|
// same context-isolation contract QueryAsync upholds, so the tree
|
|
// page's auto-load never shares the circuit-scoped context.
|
|
await using var scope = _scopeFactory!.CreateAsyncScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
|
if (_auth is null) throw new InvalidOperationException("Audit tree caller authentication is not configured.");
|
|
var principal = (await _auth.GetAuthoritativeStateAsync()).User;
|
|
if (principal.Identity?.IsAuthenticated != true || !principal.FindAll(JwtTokenService.RoleClaimType).Any(c => AuthorizationPolicies.OperationalAuditRoles.Contains(c.Value))) throw new UnauthorizedAccessException();
|
|
var sites = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToArray();
|
|
var permitted = principal.HasClaim(JwtTokenService.RoleClaimType, Roles.Administrator) || sites.Length == 0 ? null : sites;
|
|
return await repository.GetExecutionTreeAsync(executionId, ct, permitted);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
|
|
{
|
|
// Fast path: a fresh cache entry served entirely from memory.
|
|
lock (_sourceNodesLock)
|
|
{
|
|
if (_cachedSourceNodes is not null && DateTime.UtcNow < _cachedSourceNodesExpiryUtc)
|
|
{
|
|
return _cachedSourceNodes;
|
|
}
|
|
}
|
|
|
|
IReadOnlyList<string> snapshot;
|
|
if (_injectedRepository is not null)
|
|
{
|
|
snapshot = await _injectedRepository.GetDistinctSourceNodesAsync(ct);
|
|
}
|
|
else
|
|
{
|
|
await using var scope = _scopeFactory!.CreateAsyncScope();
|
|
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
|
snapshot = await repository.GetDistinctSourceNodesAsync(ct);
|
|
}
|
|
|
|
// Slow path: replace the cache entry. Concurrent slow paths can race
|
|
// here — every winner stores a valid snapshot, so the last write wins
|
|
// and no caller sees stale data. The double-check inside the lock is
|
|
// deliberately omitted: redundant DB hits during a stampede are rare
|
|
// (60s TTL) and cheaper than holding the lock across the await.
|
|
lock (_sourceNodesLock)
|
|
{
|
|
_cachedSourceNodes = snapshot;
|
|
_cachedSourceNodesExpiryUtc = DateTime.UtcNow + DistinctSourceNodesTtl;
|
|
}
|
|
|
|
return snapshot;
|
|
}
|
|
}
|