Resolve all high-severity architecture review findings

This commit is contained in:
Joseph Doherty
2026-09-09 16:13:58 -04:00
parent 8bfe65512e
commit 146a13238f
192 changed files with 3160 additions and 753 deletions
@@ -342,7 +342,7 @@ public sealed class GrpcPullAuditEventsInvoker
KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
},
}.WithSiteCredentials(_pskProvider, siteId));
}.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId));
/// <summary>Disposes all cached channels.</summary>
public void Dispose()
@@ -356,7 +356,7 @@ public sealed class GrpcPullSiteCallsInvoker
KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
},
}.WithSiteCredentials(_pskProvider, siteId));
}.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId));
/// <summary>Disposes all cached channels.</summary>
public void Dispose()
@@ -209,7 +209,7 @@ public sealed class GrpcPullTraceEventsInvoker : GrpcPullTraceEventsClient.IPull
KeepAlivePingTimeout = _options.GrpcKeepAlivePingTimeout,
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
},
}.WithSiteCredentials(_pskProvider, siteId));
}.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId));
/// <inheritdoc />
public void Dispose()
@@ -260,6 +260,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
}
var batch = new CachedTelemetryBatch();
var wireBytes = AuditWireBudget.InitialBytes;
var emittedEventIds = new List<Guid>(pending.Count);
// Rows whose operational half can never be built. They are marked
// Forwarded so they LEAVE this queue — see AbandonUnresolvableAsync
@@ -326,7 +327,7 @@ public class SiteAuditTelemetryActor : ReceiveActor
}
var packet = BuildCachedPacket(auditRow, snapshot);
batch.Packets.Add(packet);
if (!AuditWireBudget.TryAppend(batch.Packets, packet, ref wireBytes, auditRow.EventId.ToString())) break;
emittedEventIds.Add(auditRow.EventId);
}
@@ -452,9 +453,10 @@ public class SiteAuditTelemetryActor : ReceiveActor
private static AuditEventBatch BuildBatch(IReadOnlyList<AuditEvent> events)
{
var batch = new AuditEventBatch();
var wireBytes = AuditWireBudget.InitialBytes;
foreach (var e in events)
{
batch.Events.Add(AuditEventDtoMapper.ToDto(e));
if (!AuditWireBudget.TryAppend(batch.Events, AuditEventDtoMapper.ToDto(e), ref wireBytes, e.EventId.ToString())) break;
}
return batch;
}
@@ -1,3 +1,5 @@
using Microsoft.AspNetCore.Antiforgery;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
@@ -198,6 +200,7 @@ public static class AuthEndpoints
// page. Allowing anonymous access and returning 200/401 ourselves gives
// the client an unambiguous expiry signal.
endpoints.MapGet("/auth/ping", HandlePing);
endpoints.MapPost("/auth/activity", HandleActivity);
return endpoints;
}
@@ -217,6 +220,29 @@ public static class AuthEndpoints
return Task.CompletedTask;
}
/// <summary>Records real browser input only after authenticating and validating antiforgery.</summary>
public static async Task<IResult> HandleActivity(HttpContext context, IAntiforgery antiforgery,
SessionActivityTracker tracker, TimeProvider clock)
{
if (context.User.Identity?.IsAuthenticated != true) return Results.Unauthorized();
try { await antiforgery.ValidateRequestAsync(context); }
catch (AntiforgeryValidationException) { return Results.BadRequest(); }
var now = clock.GetUtcNow();
var identity = new ClaimsIdentity((ClaimsIdentity)context.User.Identity);
var old = identity.FindFirst(JwtTokenService.LastActivityClaimType);
if (old is not null) identity.RemoveClaim(old);
identity.AddClaim(new Claim(JwtTokenService.LastActivityClaimType, now.ToString("o")));
var principal = new ClaimsPrincipal(identity);
// Preserve the login's persistence semantics; renew expiry from this actual interaction.
var authenticated = await context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var properties = authenticated.Properties ?? BuildSignInProperties();
properties.IssuedUtc = now;
properties.ExpiresUtc = null;
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal, properties);
tracker.Record(principal, now);
return Results.NoContent();
}
/// <summary>
/// Builds the <see cref="AuthenticationProperties"/> for the login sign-in.
/// Deliberately does <b>not</b> set <see cref="AuthenticationProperties.ExpiresUtc"/>.
@@ -12,6 +12,20 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
/// </summary>
public static class ClaimsPrincipalExtensions
{
/// <summary>Gets fresh mutation authority, failing closed on a mapping error.</summary>
public static Task<AuthenticationState> GetAuthoritativeStateAsync(this AuthenticationStateProvider provider)
=> provider is CookieAuthenticationStateProvider cookie
? cookie.GetAuthoritativeStateAsync() : provider.GetAuthenticationStateAsync();
/// <summary>Checks the current grant before invoking a directly audited application service.</summary>
public static async Task<string> RequireRoleAsync(this AuthenticationStateProvider provider, string role)
{
var principal = (await provider.GetAuthoritativeStateAsync()).User;
if (principal.Identity?.IsAuthenticated != true || !principal.HasClaim(JwtTokenService.RoleClaimType, role))
throw new UnauthorizedAccessException($"The current session requires the {role} role.");
return principal.GetUsername();
}
/// <summary>Fallback returned when no username claim is present.</summary>
public const string UnknownUser = "unknown";
@@ -1,48 +1,71 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Security.Auth;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
/// <summary>
/// Bridges ASP.NET Core cookie authentication with Blazor Server's auth state.
/// <para>
/// The cookie middleware validates and decrypts the cookie during the initial
/// HTTP request that establishes the Blazor circuit. This provider is registered
/// <c>Scoped</c>, so it is constructed within that request's DI scope while
/// <see cref="IHttpContextAccessor.HttpContext"/> is still valid. We snapshot
/// the authenticated principal <b>once</b> in the constructor and serve that
/// snapshot for the lifetime of the circuit.
/// </para>
/// <para>
/// We must NOT read <see cref="IHttpContextAccessor"/> on every
/// <see cref="GetAuthenticationStateAsync"/> call: for the
/// lifetime of a long-lived SignalR circuit <c>HttpContext</c> is <c>null</c>
/// (or, worse, a stale/foreign context), so a later re-evaluation —
/// e.g. <c>&lt;AuthorizeView&gt;</c> re-rendering — would otherwise see an
/// unauthenticated principal and render the wrong UI.
/// </para>
/// Captures the circuit identity once, then revalidates its stored groups and session
/// against current server state. Fresh scopes avoid sharing the circuit's DbContext.
/// Never reads IHttpContextAccessor after construction.
/// </summary>
public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider
{
private readonly Task<AuthenticationState> _circuitAuthState;
private AuthenticationState _state;
private readonly IServiceScopeFactory? _scopes;
private readonly bool _autoLogin;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>
/// Snapshots the authenticated principal from the current HTTP context for use throughout the circuit lifetime.
/// </summary>
/// <param name="httpContextAccessor">Accessor used to read the initial HTTP context principal.</param>
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor)
/// <summary>Captures identity and the factory used for subsequent server-side revalidation.</summary>
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor, IServiceScopeFactory? scopes = null, IOptions<AuthDisableLoginOptions>? autoLogin = null)
{
// Snapshot the principal at circuit-construction time. HttpContext is
// valid here (initial HTTP request) and will not be afterwards.
var user = httpContextAccessor.HttpContext?.User
?? new ClaimsPrincipal(new ClaimsIdentity());
_circuitAuthState = Task.FromResult(new AuthenticationState(user));
_state = new AuthenticationState(httpContextAccessor.HttpContext?.User ?? new ClaimsPrincipal(new ClaimsIdentity()));
_scopes = scopes;
// Trusted startup configuration, already checked by DisableLoginGuard. The
// auto-login handler deliberately uses the Cookies scheme with no LDAP groups.
_autoLogin = autoLogin?.Value.DisableLogin == true;
}
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> _circuitAuthState;
public override Task<AuthenticationState> GetAuthenticationStateAsync() => RefreshAsync(failClosed: false);
/// <summary>Requires a current mapping before a mutation; mapping faults deny rather than reuse old grants.</summary>
public Task<AuthenticationState> GetAuthoritativeStateAsync() => RefreshAsync(failClosed: true);
private async Task<AuthenticationState> RefreshAsync(bool failClosed)
{
if (_autoLogin || _scopes is null || _state.User.Identity?.AuthenticationType != CookieAuthenticationDefaults.AuthenticationScheme)
return _state;
AuthenticationState state;
bool changed;
await _gate.WaitAsync();
try
{
using var scope = _scopes.CreateScope();
var validator = scope.ServiceProvider.GetRequiredService<CookieSessionValidator>();
var result = await validator.ValidateAsync(_state.User, recordActivity: false, forceRefresh: true, failClosed: failClosed);
var user = result.Action switch
{
SessionValidationAction.Replace => result.Principal!,
SessionValidationAction.Reject => new ClaimsPrincipal(new ClaimsIdentity()),
_ => _state.User,
};
changed = Authority(_state.User) != Authority(user);
_state = state = new AuthenticationState(user);
}
finally { _gate.Release(); }
if (changed) NotifyAuthenticationStateChanged(Task.FromResult(state));
return state;
}
private static string Authority(ClaimsPrincipal principal)
=> principal.Identity?.IsAuthenticated + "|" + string.Join("|", principal.Claims
.Where(c => c.Type == JwtTokenService.RoleClaimType || c.Type == JwtTokenService.SiteIdClaimType)
.Select(c => c.Type + "=" + c.Value).OrderBy(x => x, StringComparer.Ordinal));
}
@@ -37,7 +37,6 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
public sealed class SiteScopeService
{
private readonly AuthenticationStateProvider _authStateProvider;
private (bool IsSystemWide, IReadOnlySet<int> Sites)? _cached;
/// <summary>Initializes a new instance of <see cref="SiteScopeService"/>.</summary>
/// <param name="authStateProvider">The Blazor authentication state provider used to read the current user's claims.</param>
@@ -92,10 +91,7 @@ public sealed class SiteScopeService
private async Task<(bool IsSystemWide, IReadOnlySet<int> Sites)> ResolveAsync()
{
if (_cached is { } cached)
return cached;
var state = await _authStateProvider.GetAuthenticationStateAsync();
var state = await _authStateProvider.GetAuthoritativeStateAsync();
// Administrator => system-wide, whatever SiteId claims are present. Read
// the role CLAIM rather than IsInRole: the principal's role claim type is
@@ -103,6 +99,9 @@ public sealed class SiteScopeService
// by SessionClaimBuilder, and the comparison is OrdinalIgnoreCase to match
// ManagementActor's — IsInRole compares claim VALUES ordinally, which would
// make the two sides disagree on a differently-cased role name.
if (state.User.Identity?.IsAuthenticated != true)
return (false, new HashSet<int>());
var isAdministrator = state.User
.FindAll(JwtTokenService.RoleClaimType)
.Any(c => string.Equals(c.Value, Roles.Administrator, StringComparison.OrdinalIgnoreCase));
@@ -126,7 +125,6 @@ public sealed class SiteScopeService
// unrestricted deployer (this service is the sole
// site-scoping mechanism — there is no separate handler to mirror).
var result = (IsSystemWide: isAdministrator || ids.Count == 0, Sites: (IReadOnlySet<int>)ids);
_cached = result;
return result;
}
}
@@ -1,3 +1,5 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth
@using Microsoft.AspNetCore.Components.Authorization
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@@ -92,7 +94,7 @@
try
{
var actor = await ResolveActorAsync();
var actor = await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
if (IsSite)
{
@@ -1,3 +1,5 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth
@page "/admin/api-keys/create"
@page "/admin/api-keys/{KeyId}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@@ -187,6 +189,7 @@
try
{
await MutationAuth.RequireRoleAsync(Roles.Administrator);
if (_editingKey != null)
{
// Edit: name is fixed; only the method-scope set is mutable.
@@ -1,3 +1,5 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth
@page "/admin/api-keys"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
@@ -151,6 +153,7 @@
{
var newEnabled = !key.Enabled;
// The seam persists; there is no separate SaveChangesAsync.
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
var ok = await ApiKeyAdmin.SetEnabledAsync(key.KeyId, newEnabled);
if (!ok)
{
@@ -177,6 +180,7 @@
try
{
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
var ok = await ApiKeyAdmin.DeleteAsync(key.KeyId);
if (!ok)
{
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/admin/ldap-mappings/create"
@page "/admin/ldap-mappings/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security
@@ -163,19 +166,9 @@
try
{
if (_editingMapping != null)
{
_editingMapping.LdapGroupName = _formGroupName.Trim();
_editingMapping.Role = _formRole;
await SecurityRepository.UpdateMappingAsync(_editingMapping);
}
else
{
var mapping = new LdapGroupMapping(_formGroupName.Trim(), _formRole);
await SecurityRepository.AddMappingAsync(mapping);
}
await SecurityRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_editingMapping is not null
? new UpdateRoleMappingCommand(_editingMapping.Id, _formGroupName.Trim(), _formRole)
: new CreateRoleMappingCommand(_formGroupName.Trim(), _formRole));
NavigationManager.NavigateTo("/admin/ldap-mappings");
}
catch (Exception ex)
@@ -196,9 +189,7 @@
try
{
var rule = new SiteScopeRule { LdapGroupMappingId = Id!.Value, SiteId = _scopeRuleSiteId };
await SecurityRepository.AddScopeRuleAsync(rule);
await SecurityRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new AddScopeRuleCommand(Id!.Value, _scopeRuleSiteId));
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id.Value)).ToList();
_scopeRuleSiteId = 0;
}
@@ -219,8 +210,7 @@
try
{
await SecurityRepository.DeleteScopeRuleAsync(rule.Id);
await SecurityRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteScopeRuleCommand(rule.Id));
_scopeRules = (await SecurityRepository.GetScopeRulesForMappingAsync(Id!.Value)).ToList();
}
catch (Exception ex)
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/admin/ldap-mappings"
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security
@@ -143,14 +146,7 @@
{
try
{
// Also delete scope rules for this mapping
var rules = await SecurityRepository.GetScopeRulesForMappingAsync(id);
foreach (var rule in rules)
{
await SecurityRepository.DeleteScopeRuleAsync(rule.Id);
}
await SecurityRepository.DeleteMappingAsync(id);
await SecurityRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteRoleMappingCommand(id));
await LoadDataAsync();
}
catch (Exception ex)
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/admin/sites/create"
@page "/admin/sites/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@@ -158,35 +161,16 @@
try
{
if (_editingSite != null)
if (_editingSite is null && string.IsNullOrWhiteSpace(_formIdentifier))
{
_editingSite.Name = _formName.Trim();
_editingSite.Description = _formDescription?.Trim();
_editingSite.NodeAAddress = _formNodeAAddress?.Trim();
_editingSite.NodeBAddress = _formNodeBAddress?.Trim();
_editingSite.GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim();
_editingSite.GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim();
await SiteRepository.UpdateSiteAsync(_editingSite);
_formError = "Identifier is required.";
return;
}
else
{
if (string.IsNullOrWhiteSpace(_formIdentifier))
{
_formError = "Identifier is required.";
return;
}
var site = new Site(_formName.Trim(), _formIdentifier.Trim())
{
Description = _formDescription?.Trim(),
NodeAAddress = _formNodeAAddress?.Trim(),
NodeBAddress = _formNodeBAddress?.Trim(),
GrpcNodeAAddress = _formGrpcNodeAAddress?.Trim(),
GrpcNodeBAddress = _formGrpcNodeBAddress?.Trim()
};
await SiteRepository.AddSiteAsync(site);
}
await SiteRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_editingSite is not null
? new UpdateSiteCommand(_editingSite.Id, _formName.Trim(), _formDescription?.Trim(),
_formNodeAAddress?.Trim(), _formNodeBAddress?.Trim(), _formGrpcNodeAAddress?.Trim(), _formGrpcNodeBAddress?.Trim())
: new CreateSiteCommand(_formName.Trim(), _formIdentifier.Trim(), _formDescription?.Trim(),
_formNodeAAddress?.Trim(), _formNodeBAddress?.Trim(), _formGrpcNodeAAddress?.Trim(), _formGrpcNodeBAddress?.Trim()));
CommunicationService.RefreshSiteAddresses();
NavigationManager.NavigateTo("/admin/sites");
}
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@* Reference pattern for list pages: card grid (col-lg-6) + flex header + search filter + kebab dropdown + Bootstrap collapse for noisy detail + @key on iterated cards + "No X match the filter." inline + empty-state CTA. Mirror this when building new list pages. *@
@page "/admin/sites"
@using ZB.MOM.WW.ScadaBridge.Security
@@ -165,7 +168,7 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
private List<Site> _sites = new();
private Dictionary<int, List<DataConnection>> _siteConnections = new();
@@ -221,8 +224,7 @@
try
{
await SiteRepository.DeleteSiteAsync(site.Id);
await SiteRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteSiteCommand(site.Id));
CommunicationService.RefreshSiteAddresses();
_toast.ShowSuccess($"Site '{site.Name}' deleted.");
await LoadDataAsync();
@@ -1,3 +1,7 @@
@using Microsoft.Extensions.DependencyInjection
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/deployment/instances/{Id:int}/configure"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@@ -5,14 +9,12 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@using ZB.MOM.WW.ScadaBridge.Commons.Types
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening
@using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
@using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Dialogs
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
@inject ITemplateEngineRepository TemplateEngineRepository
@@ -963,8 +965,14 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
private async Task<string> GetCurrentUserAsync()
{
var user = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var current = await TemplateEngineRepository.GetInstanceByIdAsync(Id);
if (current is null || !await SiteScope.IsSiteAllowedAsync(current.SiteId))
throw new UnauthorizedAccessException("The instance is outside your current site scope.");
return user;
}
// ── Bindings ────────────────────────────────────────────
@@ -1243,7 +1251,7 @@
_bindingOverrides.GetValueOrDefault(kv.Key)))
.ToList();
var user = await GetCurrentUserAsync();
var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetConnectionBindingsAsync(Id, bindings, currentUser));
if (result.IsSuccess)
{
SnapshotSavedBindingState();
@@ -1323,8 +1331,7 @@
{
if (_existingOverrides.TryGetValue(attrName, out var ovr))
{
await TemplateEngineRepository.DeleteInstanceAttributeOverrideAsync(ovr.Id);
await TemplateEngineRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteInstanceAttributeOverrideCommand(Id, attrName));
_existingOverrides.Remove(attrName);
}
_overrideValues.Remove(attrName);
@@ -1377,7 +1384,7 @@
var failures = new List<string>();
foreach (var (attrName, value) in toSave)
{
var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAttributeOverrideAsync(Id, attrName, value, currentUser));
if (result.IsSuccess)
_existingOverrides[attrName] = result.Value!;
else
@@ -1454,7 +1461,7 @@
var failures = new List<string>();
foreach (var (attrName, value) in outcome.Overrides)
{
var result = await InstanceService.SetAttributeOverrideAsync(Id, attrName, value, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAttributeOverrideAsync(Id, attrName, value, currentUser));
if (result.IsSuccess)
{
_existingOverrides[attrName] = result.Value!;
@@ -1622,7 +1629,7 @@
{
if (_existingAlarmOverrides.ContainsKey(alarmName))
{
var del = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user);
var del = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.DeleteAlarmOverrideAsync(Id, alarmName, currentUser));
if (!del.IsSuccess)
{
_editingError = del.Error;
@@ -1639,8 +1646,8 @@
}
else
{
var result = await InstanceService.SetAlarmOverrideAsync(
Id, alarmName, overrideJson, priority, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.SetAlarmOverrideAsync(
Id, alarmName, overrideJson, priority, currentUser));
if (!result.IsSuccess)
{
_editingError = result.Error;
@@ -1675,7 +1682,7 @@
try
{
var user = await GetCurrentUserAsync();
var result = await InstanceService.DeleteAlarmOverrideAsync(Id, alarmName, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.DeleteAlarmOverrideAsync(Id, alarmName, currentUser));
if (result.IsSuccess)
{
_existingAlarmOverrides.Remove(alarmName);
@@ -1718,7 +1725,6 @@
}
await UpsertNativeOverrideCore(sourceName, conn, sref, filt);
await TemplateEngineRepository.SaveChangesAsync();
_toast.ShowSuccess($"Saved native alarm source override on '{sourceName}'.");
}
catch (Exception ex)
@@ -1751,27 +1757,8 @@
/// </summary>
private async Task UpsertNativeOverrideCore(string sourceName, string? conn, string? sref, string? filt)
{
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName);
if (existing == null)
{
var ovr = new InstanceNativeAlarmSourceOverride(sourceName)
{
InstanceId = Id,
ConnectionNameOverride = conn,
SourceReferenceOverride = sref,
ConditionFilterOverride = filt
};
await TemplateEngineRepository.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
_existingNativeOverrides[sourceName] = ovr;
}
else
{
existing.ConnectionNameOverride = conn;
existing.SourceReferenceOverride = sref;
existing.ConditionFilterOverride = filt;
await TemplateEngineRepository.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
_existingNativeOverrides[sourceName] = existing;
}
var result = await MutationServices.ExecuteConfigurationAsync(new SetInstanceNativeAlarmSourceOverrideCommand(Id, sourceName, conn, sref, filt));
if (result is InstanceNativeAlarmSourceOverride saved) _existingNativeOverrides[sourceName] = saved;
// Keep the inline editor showing what was just persisted.
_nasConnEdit[sourceName] = conn;
@@ -1781,12 +1768,7 @@
private async Task ClearNativeOverrideCore(string sourceName)
{
var existing = await TemplateEngineRepository.GetNativeAlarmSourceOverrideAsync(Id, sourceName);
if (existing != null)
{
await TemplateEngineRepository.DeleteInstanceNativeAlarmSourceOverrideAsync(existing.Id);
await TemplateEngineRepository.SaveChangesAsync();
}
await MutationServices.ExecuteConfigurationAsync(new DeleteInstanceNativeAlarmSourceOverrideCommand(Id, sourceName));
_existingNativeOverrides.Remove(sourceName);
_nasConnEdit[sourceName] = null;
_nasRefEdit[sourceName] = null;
@@ -1849,17 +1831,9 @@
return;
}
// Every row was validated up front, so the batch applies as a unit through
// the same repository upsert/clear the inline editor uses.
foreach (var row in outcome.Rows)
{
if (row.IsClear)
await ClearNativeOverrideCore(row.SourceName);
else
await UpsertNativeOverrideCore(
row.SourceName, row.Connection, row.SourceReference, row.Filter);
}
await TemplateEngineRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new SetInstanceNativeAlarmSourceOverridesCommand(Id,
outcome.Rows.Select(row => new NativeAlarmSourceOverrideEntry(row.SourceName, row.Connection, row.SourceReference, row.Filter)).ToList()));
await OnInitializedAsync();
_nasCsvImportSucceeded = true;
_nasCsvImportResult = $"Imported {outcome.Rows.Count} native alarm source override(s).";
@@ -1957,7 +1931,7 @@
try
{
var user = await GetCurrentUserAsync();
var result = await InstanceService.AssignToAreaAsync(Id, _reassignAreaId == 0 ? null : _reassignAreaId, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(Id, null, (instanceService, currentUser) => instanceService.AssignToAreaAsync(Id, _reassignAreaId == 0 ? null : _reassignAreaId, currentUser));
if (result.IsSuccess)
{
_areaName = _siteAreas.FirstOrDefault(a => a.Id == _reassignAreaId)?.Name ?? "";
@@ -1,3 +1,5 @@
@using Microsoft.Extensions.DependencyInjection
@inject IServiceProvider MutationServices
@page "/deployment/instances/create"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@@ -324,8 +326,8 @@
try
{
user = await GetCurrentUserAsync();
var result = await InstanceService.CreateInstanceAsync(
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, user);
var result = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(null, _createSiteId, (instanceService, currentUser) => instanceService.CreateInstanceAsync(
_createName.Trim(), _createTemplateId, _createSiteId, _createAreaId == 0 ? null : _createAreaId, currentUser));
if (result.IsFailure)
{
_formError = result.Error;
@@ -355,7 +357,7 @@
string? bindError = null;
try
{
var bind = await InstanceService.SetConnectionBindingsAsync(id, bindings, user);
var bind = await MutationServices.GetRequiredService<ConfigurationCommandService>().ExecuteInstanceAsync(id, null, (instanceService, currentUser) => instanceService.SetConnectionBindingsAsync(id, bindings, currentUser));
if (bind.IsFailure) bindError = bind.Error;
}
catch (Exception ex)
@@ -378,7 +380,7 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
/// <summary>
/// Opens the folder-tree template picker. A pick clears any dangling-id hint and
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/deployment/topology"
@page "/deployment/instances"
@using ZB.MOM.WW.ScadaBridge.Security
@@ -705,8 +708,7 @@
return;
}
var user = await GetCurrentUserAsync();
var result = await AreaService.UpdateAreaAsync(node.EntityId, _renameBuffer.Trim(), user);
var result = await ExecuteConfigurationAsync<Area>(new UpdateAreaCommand(node.EntityId, _renameBuffer.Trim()));
if (result.IsSuccess)
{
CancelRename();
@@ -765,8 +767,7 @@
if (req.SiteId == 0) { _createAreaError = "Select a site."; return; }
if (string.IsNullOrWhiteSpace(req.Name)) { _createAreaError = "Area name is required."; return; }
var user = await GetCurrentUserAsync();
var result = await AreaService.CreateAreaAsync(req.Name, req.SiteId, req.ParentAreaId, user);
var result = await ExecuteConfigurationAsync<Area>(new CreateAreaCommand(req.SiteId, req.Name, req.ParentAreaId));
if (result.IsSuccess)
{
_showCreateAreaDialog = false;
@@ -800,8 +801,7 @@
private async Task SubmitMoveArea((int AreaId, int? NewParentId) req)
{
_moveAreaError = null;
var user = await GetCurrentUserAsync();
var result = await AreaService.MoveAreaAsync(req.AreaId, req.NewParentId, user);
var result = await ExecuteConfigurationAsync<Area>(new MoveAreaCommand(req.AreaId, req.NewParentId));
if (result.IsSuccess)
{
_showMoveAreaDialog = false;
@@ -836,8 +836,7 @@
private async Task SubmitMoveInstance((int InstanceId, int? NewAreaId) req)
{
_moveInstanceError = null;
var user = await GetCurrentUserAsync();
var result = await InstanceService.AssignToAreaAsync(req.InstanceId, req.NewAreaId, user);
var result = await ExecuteConfigurationAsync<Instance>(new SetInstanceAreaCommand(req.InstanceId, req.NewAreaId));
if (result.IsSuccess)
{
_showMoveInstanceDialog = false;
@@ -860,8 +859,7 @@
if (!confirmed) return;
_actionInProgress = true;
var user = await GetCurrentUserAsync();
var result = await AreaService.DeleteAreaAsync(node.EntityId, user);
var result = await ExecuteConfigurationAsync<bool>(new DeleteAreaCommand(node.EntityId));
if (result.IsSuccess)
{
_toast.ShowSuccess($"Area '{node.Label}' deleted.");
@@ -885,7 +883,8 @@
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var user = await GetCurrentUserAsync(inst.Id);
if (user is null) return;
var result = await DeploymentService.DeleteInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
@@ -910,7 +909,8 @@
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var user = await GetCurrentUserAsync(inst.Id);
if (user is null) return;
var result = await DeploymentService.EnableInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
@@ -941,7 +941,8 @@
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var user = await GetCurrentUserAsync(inst.Id);
if (user is null) return;
var result = await DeploymentService.DisableInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
@@ -965,7 +966,8 @@
_actionInProgress = true;
try
{
var user = await GetCurrentUserAsync();
var user = await GetCurrentUserAsync(inst.Id);
if (user is null) return;
var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
@@ -1239,6 +1241,35 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
private async Task<string?> GetCurrentUserAsync(int instanceId)
{
var user = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
// Re-fetch outside the circuit tracker: the instance may have moved since rendering.
using var scope = ScopeFactory.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<ITemplateEngineRepository>();
var instance = await repository.GetInstanceByIdAsync(instanceId);
if (instance is null || !await SiteScope.IsSiteAllowedAsync(instance.SiteId))
{
_toast.ShowError("This instance is no longer available within your permitted sites.");
_actionInProgress = false;
return null;
}
return user;
}
private async Task<ZB.MOM.WW.ScadaBridge.Commons.Types.Result<T>> ExecuteConfigurationAsync<T>(object command)
{
try
{
var value = await MutationServices.ExecuteConfigurationAsync(command);
return ZB.MOM.WW.ScadaBridge.Commons.Types.Result<T>.Success((T)value!);
}
catch (Exception ex)
{
var message = ex is UnauthorizedAccessException
or ZB.MOM.WW.ScadaBridge.ManagementService.ManagementCommandException
? ex.Message : "The configuration change failed. Refresh the page and try again.";
return ZB.MOM.WW.ScadaBridge.Commons.Types.Result<T>.Failure(message);
}
}
}
@@ -1,10 +1,14 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/api-methods/create"
@page "/design/api-methods/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ScriptAnalysis = ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject IInboundApiRepository InboundApiRepository
@@ -297,33 +301,10 @@
try
{
// Save the ApiMethod entity FIRST so the method Name exists before we reconcile
// key scopes against it. The method entity stays in SQL Server; we leave the
// (now-legacy) ApprovedApiKeyIds column untouched — it is dropped in C5.
string methodName;
if (_existing != null)
{
_existing.Script = _script;
_existing.TimeoutSeconds = _timeoutSeconds;
_existing.TraceAllCalls = _traceAllCalls;
_existing.ParameterDefinitions = _params?.Trim();
_existing.ReturnDefinition = _returns?.Trim();
await InboundApiRepository.UpdateApiMethodAsync(_existing);
methodName = _existing.Name;
}
else
{
var m = new ApiMethod(_name.Trim(), _script)
{
TimeoutSeconds = _timeoutSeconds,
TraceAllCalls = _traceAllCalls,
ParameterDefinitions = _params?.Trim(),
ReturnDefinition = _returns?.Trim()
};
await InboundApiRepository.AddApiMethodAsync(m);
methodName = m.Name;
}
await InboundApiRepository.SaveChangesAsync();
var methodName = _existing?.Name ?? _name.Trim();
await MutationServices.ExecuteConfigurationAsync(_existing is not null
? new UpdateApiMethodCommand(_existing.Id, _script, _timeoutSeconds, _params?.Trim(), _returns?.Trim(), _traceAllCalls)
: new CreateApiMethodCommand(methodName, _script, _timeoutSeconds, _params?.Trim(), _returns?.Trim(), _traceAllCalls));
// Reconcile per-key method-scopes for the affected keys (added/removed vs. load time).
if (!await ReconcileKeyScopesAsync(methodName)) return;
@@ -371,6 +352,7 @@
{
foreach (var update in plan.Updates)
{
await MutationAuth.RequireRoleAsync(Roles.Designer);
var ok = await ApiKeyAdmin.SetMethodsAsync(update.KeyId, update.NewMethods);
if (!ok)
throw new InvalidOperationException(
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/connections/create"
@page "/design/connections/{Id:int}/edit"
@page "/design/data-connections/create"
@@ -327,26 +330,9 @@
try
{
if (_editingConnection != null)
{
_editingConnection.Name = _formName.Trim();
_editingConnection.Protocol = _protocol;
_editingConnection.PrimaryConfiguration = primaryJson;
_editingConnection.BackupConfiguration = backupJson;
_editingConnection.FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3;
await SiteRepository.UpdateDataConnectionAsync(_editingConnection);
}
else
{
var conn = new DataConnection(_formName.Trim(), _protocol, _formSiteId)
{
PrimaryConfiguration = primaryJson,
BackupConfiguration = backupJson,
FailoverRetryCount = _showBackup ? _formFailoverRetryCount : 3
};
await SiteRepository.AddDataConnectionAsync(conn);
}
await SiteRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_editingConnection is not null
? new UpdateDataConnectionCommand(_editingConnection.Id, _formName.Trim(), _protocol, primaryJson, backupJson, _showBackup ? _formFailoverRetryCount : 3)
: new CreateDataConnectionCommand(_formSiteId, _formName.Trim(), _protocol, primaryJson, backupJson, _showBackup ? _formFailoverRetryCount : 3));
NavigationManager.NavigateTo("/design/connections");
}
catch (Exception ex)
@@ -1,7 +1,9 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/connections"
@page "/design/data-connections"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@@ -417,8 +419,7 @@
try
{
await SiteRepository.DeleteDataConnectionAsync(conn.Id);
await SiteRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteDataConnectionCommand(conn.Id));
_toast.ShowSuccess($"Connection '{conn.Name}' deleted.");
await LoadDataAsync();
}
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/db-connections/create"
@page "/design/db-connections/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@@ -94,24 +97,9 @@
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
_existing.ConnectionString = _connectionString.Trim();
_existing.MaxRetries = _maxRetries;
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
await ExternalSystemRepository.UpdateDatabaseConnectionAsync(_existing);
}
else
{
var dc = new DatabaseConnectionDefinition(_name.Trim(), _connectionString.Trim())
{
MaxRetries = _maxRetries,
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds)
};
await ExternalSystemRepository.AddDatabaseConnectionAsync(dc);
}
await ExternalSystemRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_existing is not null
? new UpdateDatabaseConnectionDefCommand(_existing.Id, _name.Trim(), _connectionString.Trim(), _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds))
: new CreateDatabaseConnectionDefCommand(_name.Trim(), _connectionString.Trim(), _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds)));
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/external-systems/create"
@page "/design/external-systems/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@@ -137,29 +140,9 @@
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
_existing.EndpointUrl = _endpointUrl.Trim();
_existing.AuthType = _authType;
_existing.AuthConfiguration = _authConfig?.Trim();
_existing.MaxRetries = _maxRetries;
_existing.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
_existing.TimeoutSeconds = _timeoutSeconds;
await ExternalSystemRepository.UpdateExternalSystemAsync(_existing);
}
else
{
var es = new ExternalSystemDefinition(_name.Trim(), _endpointUrl.Trim(), _authType)
{
AuthConfiguration = _authConfig?.Trim(),
MaxRetries = _maxRetries,
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds),
TimeoutSeconds = _timeoutSeconds
};
await ExternalSystemRepository.AddExternalSystemAsync(es);
}
await ExternalSystemRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_existing is not null
? new UpdateExternalSystemCommand(_existing.Id, _name.Trim(), _endpointUrl.Trim(), _authType, _authConfig?.Trim() ?? "", _timeoutSeconds, _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds))
: new CreateExternalSystemCommand(_name.Trim(), _endpointUrl.Trim(), _authType, _authConfig?.Trim(), _timeoutSeconds, _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds)));
NavigationManager.NavigateTo("/design/external-systems");
}
catch (Exception ex) { _formError = ex.Message; }
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/external-systems"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems
@@ -189,7 +192,7 @@
private async Task DeleteExtSys(ExternalSystemDefinition es)
{
if (!await Dialog.ConfirmAsync("Delete External System", $"Delete '{es.Name}'?", danger: true)) return;
try { await ExternalSystemRepository.DeleteExternalSystemAsync(es.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
try { await MutationServices.ExecuteConfigurationAsync(new DeleteExternalSystemCommand(es.Id)); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
@@ -262,7 +265,7 @@
private async Task DeleteDbConn(DatabaseConnectionDefinition dc)
{
if (!await Dialog.ConfirmAsync("Delete DB Connection", $"Delete '{dc.Name}'?", danger: true)) return;
try { await ExternalSystemRepository.DeleteDatabaseConnectionAsync(dc.Id); await ExternalSystemRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
try { await MutationServices.ExecuteConfigurationAsync(new DeleteDatabaseConnectionDefCommand(dc.Id)); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
@@ -337,7 +340,7 @@
private async Task DeleteApiMethod(ApiMethod m)
{
if (!await Dialog.ConfirmAsync("Delete", $"Delete API method '{m.Name}'?", danger: true)) return;
try { await InboundApiRepository.DeleteApiMethodAsync(m.Id); await InboundApiRepository.SaveChangesAsync(); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
try { await MutationServices.ExecuteConfigurationAsync(new DeleteApiMethodCommand(m.Id)); _toast.ShowSuccess("Deleted."); await LoadAllAsync(); }
catch (Exception ex) { _toast.ShowError(ex.Message); }
}
@@ -207,7 +207,7 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
protected override async Task OnInitializedAsync()
{
@@ -104,7 +104,7 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
private List<SharedScript> _scripts = new();
private bool _loading = true;
@@ -122,5 +122,5 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
}
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/design/templates/{Id:int}"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances
@@ -217,7 +220,7 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
private RenderFragment RenderTemplateDetail() => __builder =>
{
@@ -1080,13 +1083,10 @@
{
var existing = await TemplateEngineRepository.GetTemplateNativeAlarmSourceByIdAsync(id);
if (existing == null) return "Source no longer exists.";
existing.ConnectionName = draft.ConnectionName.Trim();
existing.SourceReference = draft.SourceReference.Trim();
existing.ConditionFilter = string.IsNullOrWhiteSpace(draft.ConditionFilter) ? null : draft.ConditionFilter.Trim();
existing.Description = string.IsNullOrWhiteSpace(draft.Description) ? null : draft.Description.Trim();
existing.IsLocked = draft.IsLocked;
await TemplateEngineRepository.UpdateTemplateNativeAlarmSourceAsync(existing);
await TemplateEngineRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new UpdateTemplateNativeAlarmSourceCommand(id, existing.Name,
draft.ConnectionName.Trim(), draft.SourceReference.Trim(),
string.IsNullOrWhiteSpace(draft.ConditionFilter) ? null : draft.ConditionFilter.Trim(),
string.IsNullOrWhiteSpace(draft.Description) ? null : draft.Description.Trim(), draft.IsLocked));
_toast.ShowSuccess($"Native alarm source '{existing.Name}' updated.");
return null;
}
@@ -1105,8 +1105,8 @@
Description = string.IsNullOrWhiteSpace(draft.Description) ? null : draft.Description.Trim(),
IsLocked = draft.IsLocked
};
await TemplateEngineRepository.AddTemplateNativeAlarmSourceAsync(source);
await TemplateEngineRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new AddTemplateNativeAlarmSourceCommand(source.TemplateId, source.Name,
source.ConnectionName, source.SourceReference, source.ConditionFilter, source.Description, source.IsLocked));
_toast.ShowSuccess($"Native alarm source '{source.Name}' added.");
return null;
}
@@ -1115,8 +1115,7 @@
{
var confirmed = await Dialog.ConfirmAsync("Delete Native Alarm Source", $"Delete native alarm source '{src.Name}'?", danger: true);
if (!confirmed) return;
await TemplateEngineRepository.DeleteTemplateNativeAlarmSourceAsync(src.Id);
await TemplateEngineRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteTemplateNativeAlarmSourceCommand(src.Id));
_toast.ShowSuccess($"Native alarm source '{src.Name}' deleted.");
await LoadAsync();
}
@@ -118,7 +118,7 @@
// CentralUI-024: delegates to the shared helper so the claim type stays
// resolved through JwtTokenService rather than a duplicated magic string.
private Task<string> GetCurrentUserAsync()
=> AuthStateProvider.GetCurrentUsernameAsync();
=> AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
private List<Template> _templates = new();
private List<TemplateFolder> _folders = new();
@@ -382,7 +382,7 @@ public partial class TransportExport : ComponentBase
_downloadError = null;
try
{
var user = await Auth.GetCurrentUsernameAsync();
var user = await Auth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Designer);
var sourceEnv = TransportOptions.Value.SourceEnvironment;
if (string.IsNullOrWhiteSpace(sourceEnv))
{
@@ -465,7 +465,7 @@ public partial class TransportImport : ComponentBase, IDisposable
{
try
{
var user = await Auth.GetCurrentUsernameAsync();
var user = await Auth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
var entityName = _session?.Manifest.SourceEnvironment ?? "<unknown>";
await AuditService.LogAsync(
user: user,
@@ -857,7 +857,7 @@ public partial class TransportImport : ComponentBase, IDisposable
_result = null;
try
{
var user = await Auth.GetCurrentUsernameAsync();
var user = await Auth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Administrator);
// Fold the operator's Map-step choices into the name map. For
// central-config-only bundles (no required mappings) this is
// BundleNameMap.Empty, which the importer normalises away.
@@ -1,3 +1,5 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Auth
@inject Microsoft.AspNetCore.Components.Authorization.AuthenticationStateProvider MutationAuth
@page "/monitoring/parked-messages"
@attribute [Authorize(Policy = ZB.MOM.WW.ScadaBridge.Security.AuthorizationPolicies.RequireDeployment)]
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites
@@ -647,7 +649,10 @@
try
{
var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow);
var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId);
if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked.");
var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
if (resp.Success) success++; else failed++;
}
catch { failed++; }
@@ -680,7 +685,10 @@
try
{
var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, id, DateTimeOffset.UtcNow);
var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId);
if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked.");
var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
if (resp.Success) success++; else failed++;
}
catch { failed++; }
@@ -702,6 +710,9 @@
try
{
var req = new ParkedMessageRetryRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow);
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId);
if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked.");
var resp = await CommunicationService.RetryParkedMessageAsync(_selectedSiteId, req);
if (resp.Success)
{
@@ -731,6 +742,9 @@
try
{
var req = new ParkedMessageDiscardRequest(Guid.NewGuid().ToString("N"), _selectedSiteId, msg.MessageId, DateTimeOffset.UtcNow);
await MutationAuth.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var mutationSite = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == _selectedSiteId);
if (mutationSite is null || !await SiteScope.IsSiteAllowedAsync(mutationSite.Id)) throw new UnauthorizedAccessException("Site access was revoked.");
var resp = await CommunicationService.DiscardParkedMessageAsync(_selectedSiteId, req);
if (resp.Success)
{
@@ -1,7 +1,9 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/notifications/lists/create"
@page "/notifications/lists/{Id:int}/edit"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@@ -185,20 +187,9 @@
try
{
if (_existing != null)
{
_existing.Name = _name.Trim();
await NotificationRepository.UpdateNotificationListAsync(_existing);
}
else
{
// Type is set at create time and never changes thereafter (the selector
// is disabled on edit) so a list's recipients always carry the matching
// contact field.
var nl = new NotificationList(_name.Trim()) { Type = _type };
await NotificationRepository.AddNotificationListAsync(nl);
}
await NotificationRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_existing is not null
? new RenameNotificationListCommand(_existing.Id, _name.Trim())
: new CreateNotificationListCommand(_name.Trim(), [], _type));
NavigationManager.NavigateTo("/notifications/lists");
}
catch (Exception ex) { _formError = ex.Message; }
@@ -238,8 +229,7 @@
try
{
await NotificationRepository.AddRecipientAsync(r);
await NotificationRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new AddNotificationRecipientCommand(r.NotificationListId, r.Name, r.EmailAddress, r.PhoneNumber));
_recipientName = _recipientEmail = _recipientPhone = string.Empty;
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id.Value)).ToList();
}
@@ -264,8 +254,7 @@
{
try
{
await NotificationRepository.DeleteRecipientAsync(r.Id);
await NotificationRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteNotificationRecipientCommand(r.Id));
_recipients = (await NotificationRepository.GetRecipientsByListIdAsync(Id!.Value)).ToList();
}
catch (Exception ex) { _recipientFormError = ex.Message; }
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/notifications/lists"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
@@ -165,8 +168,7 @@
}
try
{
await NotificationRepository.DeleteNotificationListAsync(list.Id);
await NotificationRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(new DeleteNotificationListCommand(list.Id));
_toast.ShowSuccess("Deleted.");
await LoadAsync();
}
@@ -604,7 +604,8 @@
_actionInProgress = true;
try
{
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
if (!await IsRowSiteAllowedAsync(n.SourceSiteId)) throw new UnauthorizedAccessException("Site access was revoked.");
var requestedBy = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var response = await CommunicationService.RetryNotificationAsync(
new RetryNotificationRequest(Guid.NewGuid().ToString("N"), n.NotificationId, requestedBy));
if (response.Success)
@@ -642,7 +643,8 @@
_actionInProgress = true;
try
{
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
if (!await IsRowSiteAllowedAsync(n.SourceSiteId)) throw new UnauthorizedAccessException("Site access was revoked.");
var requestedBy = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var response = await CommunicationService.DiscardNotificationAsync(
new DiscardNotificationRequest(Guid.NewGuid().ToString("N"), n.NotificationId, requestedBy));
if (response.Success)
@@ -848,6 +850,9 @@
return _permittedSiteIds.Contains(resolved.Id);
}
private Task<bool> IsRowSiteAllowedAsync(string sourceSiteId)
=> Task.FromResult(IsRowSiteAllowedSync(sourceSiteId));
private async Task<bool> IsRowSiteAllowedAsync(string sourceSiteId)
{
var resolved = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == sourceSiteId);
return resolved is not null && await SiteScope.IsSiteAllowedAsync(resolved.Id);
}
}
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/notifications/sms"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@@ -245,48 +248,18 @@
try
{
if (_editingSms != null)
if (_editingSms is null && typedAuthToken is null)
{
_editingSms.AccountSid = _accountSid.Trim();
_editingSms.FromNumber = string.IsNullOrWhiteSpace(_fromNumber) ? null : _fromNumber.Trim();
_editingSms.MessagingServiceSid = string.IsNullOrWhiteSpace(_messagingServiceSid)
? null
: _messagingServiceSid.Trim();
_editingSms.ApiBaseUrl = string.IsNullOrWhiteSpace(_apiBaseUrl) ? null : _apiBaseUrl.Trim();
// Preserve-if-blank: only overwrite the stored token when a new value was typed.
if (typedAuthToken != null)
{
_editingSms.AuthToken = typedAuthToken;
}
_editingSms.ConnectionTimeoutSeconds = _connectionTimeoutSeconds;
_editingSms.MaxRetries = _maxRetries;
_editingSms.RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds);
await NotificationRepository.UpdateSmsConfigurationAsync(_editingSms);
_formError = "Auth Token is required.";
return;
}
else
{
if (typedAuthToken == null)
{
_formError = "Auth Token is required.";
return;
}
var sms = new SmsConfigurationEntity(
_accountSid.Trim(),
string.IsNullOrWhiteSpace(_fromNumber) ? null : _fromNumber.Trim())
{
MessagingServiceSid = string.IsNullOrWhiteSpace(_messagingServiceSid)
? null
: _messagingServiceSid.Trim(),
ApiBaseUrl = string.IsNullOrWhiteSpace(_apiBaseUrl) ? null : _apiBaseUrl.Trim(),
AuthToken = typedAuthToken,
ConnectionTimeoutSeconds = _connectionTimeoutSeconds,
MaxRetries = _maxRetries,
RetryDelay = TimeSpan.FromSeconds(_retryDelaySeconds),
};
await NotificationRepository.AddSmsConfigurationAsync(sms);
}
await NotificationRepository.SaveChangesAsync();
var from = string.IsNullOrWhiteSpace(_fromNumber) ? null : _fromNumber.Trim();
var messaging = string.IsNullOrWhiteSpace(_messagingServiceSid) ? null : _messagingServiceSid.Trim();
await MutationServices.ExecuteConfigurationAsync(_editingSms is not null
? new UpdateSmsConfigCommand(_editingSms.Id, _accountSid.Trim(), from, messaging, _apiBaseUrl?.Trim() ?? "", typedAuthToken,
_connectionTimeoutSeconds, _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds))
: new CreateSmsConfigCommand(_accountSid.Trim(), from, messaging, _apiBaseUrl?.Trim(), typedAuthToken,
_connectionTimeoutSeconds, _maxRetries, TimeSpan.FromSeconds(_retryDelaySeconds)));
_showForm = false;
_toast.ShowSuccess("SMS configuration saved.");
await LoadAsync();
@@ -1,3 +1,6 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management
@inject IServiceProvider MutationServices
@page "/notifications/smtp"
@using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@@ -345,33 +348,11 @@
try
{
if (_editingSmtp != null)
{
_editingSmtp.Host = _host.Trim();
_editingSmtp.Port = _port;
_editingSmtp.Transport = _transport;
_editingSmtp.AuthType = _authType;
_editingSmtp.TlsMode = _tlsMode;
_editingSmtp.Credentials = _credentials?.Trim();
_editingSmtp.FromAddress = _fromAddress.Trim();
_editingSmtp.OAuth2Authority = NormalizeOptional(_oauth2Authority);
_editingSmtp.OAuth2Scope = NormalizeOptional(_oauth2Scope);
await NotificationRepository.UpdateSmtpConfigurationAsync(_editingSmtp);
}
else
{
var smtp = new SmtpConfigurationEntity(_host.Trim(), _authType, _fromAddress.Trim())
{
Port = _port,
Transport = _transport,
TlsMode = _tlsMode,
Credentials = _credentials?.Trim(),
OAuth2Authority = NormalizeOptional(_oauth2Authority),
OAuth2Scope = NormalizeOptional(_oauth2Scope)
};
await NotificationRepository.AddSmtpConfigurationAsync(smtp);
}
await NotificationRepository.SaveChangesAsync();
await MutationServices.ExecuteConfigurationAsync(_editingSmtp is not null
? new UpdateSmtpConfigCommand(_editingSmtp.Id, _host.Trim(), _port, _authType, _fromAddress.Trim(), _tlsMode,
_credentials?.Trim() ?? "", NormalizeOptional(_oauth2Authority) ?? "", NormalizeOptional(_oauth2Scope) ?? "", _transport)
: new CreateSmtpConfigCommand(_host.Trim(), _port, _authType, _fromAddress.Trim(), _tlsMode,
_credentials?.Trim(), NormalizeOptional(_oauth2Authority), NormalizeOptional(_oauth2Scope), _transport));
_showForm = false;
_toast.ShowSuccess("SMTP configuration saved.");
await LoadAsync();
@@ -338,7 +338,8 @@ public partial class SiteCallsReport
_actionInProgress = true;
try
{
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
if (!await IsRowSiteAllowedAsync(c.SourceSite)) throw new UnauthorizedAccessException("Site access was revoked.");
var requestedBy = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var response = await CommunicationService.RetrySiteCallAsync(
new RetrySiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite, requestedBy));
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
@@ -374,7 +375,8 @@ public partial class SiteCallsReport
_actionInProgress = true;
try
{
var requestedBy = await AuthStateProvider.GetCurrentUsernameAsync();
if (!await IsRowSiteAllowedAsync(c.SourceSite)) throw new UnauthorizedAccessException("Site access was revoked.");
var requestedBy = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
var response = await CommunicationService.DiscardSiteCallAsync(
new DiscardSiteCallRequest(Guid.NewGuid().ToString("N"), c.TrackedOperationId, c.SourceSite, requestedBy));
ShowRelayOutcome(response.Outcome, response.SiteReachable, response.ErrorMessage,
@@ -589,16 +591,10 @@ public partial class SiteCallsReport
/// Server-side re-check for the Retry/Discard relay. True for a system-wide
/// user, or when the row's source site maps to a Site.Id in the permitted set.
/// </summary>
private Task<bool> IsRowSiteAllowedAsync(string sourceSite)
private async Task<bool> IsRowSiteAllowedAsync(string sourceSite)
{
if (_siteScopeSystemWide)
return Task.FromResult(true);
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == sourceSite);
if (resolved is null)
return Task.FromResult(true);
return Task.FromResult(_permittedSiteIds.Contains(resolved.Id));
var resolved = (await SiteRepository.GetAllSitesAsync()).FirstOrDefault(s => s.SiteIdentifier == sourceSite);
return resolved is not null && await SiteScope.IsSiteAllowedAsync(resolved.Id);
}
// ── Trends ──────────────────────────────────────────────────────────────
@@ -2,28 +2,11 @@
@inject NavigationManager Navigation
@inject IJSRuntime JS
<AntiforgeryToken />
@code {
// CentralUI-005 / CentralUI-020: session expiry is a sliding window owned by
// the cookie authentication middleware (ZB.MOM.WW.ScadaBridge.Security AddCookie:
// ExpireTimeSpan = idle timeout, SlidingExpiration = true). An active user's
// cookie is continually renewed; an idle user's cookie lapses after the idle
// timeout. There is no fixed login-time deadline to redirect at.
//
// This component must NOT poll the Blazor AuthenticationStateProvider:
// CookieAuthenticationStateProvider serves a frozen constructor-time
// principal for the whole circuit (CentralUI-004), so the polled auth state
// can never transition to "expired" and the redirect would never fire
// (CentralUI-020).
//
// Instead it polls the server endpoint GET /auth/ping via fetch(). Being a
// normal HTTP request, the cookie middleware re-validates — and slides — the
// cookie on every hit, and answers 200 while the session is live or 401 once
// it has lapsed. A genuine idle user's circuit produces no other HTTP
// traffic, so once the cookie lapses the next ping returns 401 and this
// component redirects to /login. (The ping itself slides the cookie, but the
// poll interval is well under the idle timeout, so an idle session still
// lapses on schedule once the poll catches the lapsed state — the ping only
// ever observes expiry, it does not keep a dead session alive.)
// Passive HTTP probes observe expiry but never advance activity. The JS module
// separately reports trusted browser input through an antiforgery-protected POST.
/// <summary>Server endpoint that reports live session validity.</summary>
internal const string PingUrl = "/auth/ping";
@@ -49,6 +32,14 @@
_ = PollSessionAsync(_cts.Token);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || IsOnLoginPage) return;
try { _module ??= await JS.InvokeAsync<IJSObjectReference>("import", ModulePath); }
catch (JSDisconnectedException) { }
catch (JSException) { }
}
private bool IsOnLoginPage =>
Navigation.ToBaseRelativePath(Navigation.Uri)
.StartsWith("login", StringComparison.OrdinalIgnoreCase);
@@ -37,6 +37,7 @@ public static class ServiceCollectionExtensions
// Resolves the current user's permitted site set from their SiteId claims
// so Deployment/Monitoring pages can enforce site scoping.
services.AddScoped<SiteScopeService>();
services.AddScoped<ConfigurationCommandService>();
// Centralised dialog service: pages inject IDialogService and a single
// <DialogHost /> in MainLayout renders the active dialog. See
@@ -1,3 +1,4 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using Microsoft.AspNetCore.Components.Authorization;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Communication;
@@ -39,7 +40,7 @@ public sealed class BindingTester : IBindingTester
// roles, so the Designer check must happen here before any cross-cluster
// traffic. Use HasClaim against JwtTokenService.RoleClaimType (not
// IsInRole, per c1e16cf).
var state = await _auth.GetAuthenticationStateAsync();
var state = await _auth.GetAuthoritativeStateAsync();
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
{
return new ReadTagValuesResult(
@@ -1,3 +1,4 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using Microsoft.AspNetCore.Components.Authorization;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
@@ -45,7 +46,7 @@ public sealed class BrowseService : IBrowseService
{
// CentralUI-side role guard — sites don't enforce envelope-level roles,
// so the Designer check must happen here before any cross-cluster traffic.
var state = await _auth.GetAuthenticationStateAsync();
var state = await _auth.GetAuthoritativeStateAsync();
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
{
return new BrowseNodeResult(
@@ -100,7 +101,7 @@ public sealed class BrowseService : IBrowseService
// Same CentralUI-side role guard as BrowseChildrenAsync — sites don't
// enforce envelope-level roles, so the Designer check must happen here
// before any cross-cluster traffic.
var state = await _auth.GetAuthenticationStateAsync();
var state = await _auth.GetAuthoritativeStateAsync();
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
{
return new SearchAddressSpaceResult(
@@ -1,3 +1,4 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using Microsoft.AspNetCore.Components.Authorization;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Communication;
@@ -142,7 +143,7 @@ public sealed class CertManagementService : ICertManagementService
private async Task<bool> HasRoleAsync(string role)
{
var state = await _auth.GetAuthenticationStateAsync();
var state = await _auth.GetAuthoritativeStateAsync();
return state.User.HasClaim(JwtTokenService.RoleClaimType, role);
}
}
@@ -0,0 +1,57 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>Fresh caller authorization and audited configuration commands for page event handlers.</summary>
public sealed class ConfigurationCommandService(IServiceProvider services, AuthenticationStateProvider auth)
{
/// <summary>Runs an existing audited instance operation with fresh scope and an atomic audit commit.</summary>
public async Task<T> ExecuteInstanceAsync<T>(int? instanceId, int? createSiteId,
Func<ZB.MOM.WW.ScadaBridge.TemplateEngine.Services.InstanceService, string, Task<T>> operation)
{
var username = await auth.RequireRoleAsync(Roles.Deployer);
var sites = new SiteScopeService(auth);
try
{
return (T)(await services.GetRequiredService<IConfigurationCommandTransaction>().ExecuteAsync(async sp =>
{
var siteId = createSiteId;
if (instanceId.HasValue)
{
var instance = await sp.GetRequiredService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.ITemplateEngineRepository>().GetInstanceByIdAsync(instanceId.Value);
siteId = instance?.SiteId;
}
if (!siteId.HasValue || !await sites.IsSiteAllowedAsync(siteId.Value))
throw new UnauthorizedAccessException("Instance site access was revoked.");
return await operation(sp.GetRequiredService<ZB.MOM.WW.ScadaBridge.TemplateEngine.Services.InstanceService>(), username);
}))!;
}
finally { services.GetService<IConfigurationReadSession>()?.Reset(); }
}
/// <summary>Executes one command through the same guarded boundary as the management actor.</summary>
public async Task<object?> ExecuteAsync(object command)
{
var principal = (await auth.GetAuthoritativeStateAsync()).User;
if (principal.Identity?.IsAuthenticated != true) throw new UnauthorizedAccessException("Session expired.");
var user = new AuthenticatedUser(principal.GetUsername(), principal.GetDisplayName() ?? principal.GetUsername(),
principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToArray(),
principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToArray());
try { return await ManagementActor.ExecuteConfigurationCommandAsync(services, command, user, services.GetService<CircuitClientAddressProvider>()?.ClientAddress); }
finally { services.GetService<IConfigurationReadSession>()?.Reset(); }
}
}
/// <summary>Resolves the mutation facade only when a page acts, keeping read-only rendering independent.</summary>
public static class ConfigurationCommandExtensions
{
/// <summary>Submits a database-only configuration mutation with current server-side authority.</summary>
public static Task<object?> ExecuteConfigurationAsync(this IServiceProvider services, object command)
=> services.GetRequiredService<ConfigurationCommandService>().ExecuteAsync(command);
}
@@ -1,3 +1,4 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using System.Security.Claims;
using Akka.Actor;
using Microsoft.AspNetCore.Components.Authorization;
@@ -106,7 +107,7 @@ public sealed class DataConnectionMoveService : IDataConnectionMoveService
/// </summary>
private async Task<AuthenticatedUser> BuildAuthenticatedUserAsync()
{
var state = await _auth.GetAuthenticationStateAsync();
var state = await _auth.GetAuthoritativeStateAsync();
var principal = state.User;
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown";
@@ -1,3 +1,4 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using Microsoft.AspNetCore.Components.Authorization;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Serialization;
@@ -47,7 +48,7 @@ public sealed class EndpointVerificationService : IEndpointVerificationService
{
// CentralUI-side role guard — sites don't enforce envelope-level roles, so the
// Designer check must happen here before any cross-cluster traffic.
var state = await _auth.GetAuthenticationStateAsync();
var state = await _auth.GetAuthoritativeStateAsync();
if (!state.User.HasClaim(JwtTokenService.RoleClaimType, Roles.Designer))
{
return new VerifyEndpointResult(
@@ -299,7 +299,7 @@ public sealed class FlowDebugService : IFlowDebugService
var shortId = c.ToString("N")[..12];
try
{
var requestedBy = await _auth.GetCurrentUsernameAsync();
var requestedBy = await _auth.RequireRoleAsync(Roles.Deployer);
var corr = Guid.NewGuid().ToString("N");
if (isNotify)
@@ -580,7 +580,7 @@ public sealed class FlowDebugService : IFlowDebugService
private async Task<AuthenticatedUser> BuildAuthenticatedUserAsync()
{
var state = await _auth.GetAuthenticationStateAsync();
var state = await _auth.GetAuthoritativeStateAsync();
var principal = state.User;
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown";
@@ -1,3 +1,4 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using System.Security.Claims;
using System.Text.Json;
using Akka.Actor;
@@ -144,7 +145,7 @@ public sealed class SchemaLibraryService : ISchemaLibraryService
/// </summary>
private async Task<AuthenticatedUser> BuildAuthenticatedUserAsync()
{
var state = await _auth.GetAuthenticationStateAsync();
var state = await _auth.GetAuthoritativeStateAsync();
var principal = state.User;
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown";
@@ -1,3 +1,4 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using System.Security.Claims;
using System.Text.Json;
using Akka.Actor;
@@ -165,7 +166,7 @@ public sealed class SecuredWriteService : ISecuredWriteService
/// </summary>
private async Task<AuthenticatedUser> BuildAuthenticatedUserAsync()
{
var state = await _auth.GetAuthenticationStateAsync();
var state = await _auth.GetAuthoritativeStateAsync();
var principal = state.User;
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown";
@@ -1,3 +1,4 @@
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using System.Security.Claims;
using System.Text.Json;
using Akka.Actor;
@@ -112,7 +113,7 @@ public sealed class TemplateInheritanceQueryService : ITemplateInheritanceQueryS
/// </summary>
private async Task<AuthenticatedUser> BuildAuthenticatedUserAsync()
{
var state = await _auth.GetAuthenticationStateAsync();
var state = await _auth.GetAuthoritativeStateAsync();
var principal = state.User;
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "unknown";
@@ -21,3 +21,24 @@ export async function ping(url) {
return 0;
}
}
// SignalR event traffic and automatic pings are not user activity. Only trusted
// browser input can schedule this antiforgery-protected request.
let lastReported = 0;
let reporting = false;
async function recordInput(event) {
if (!event.isTrusted || reporting || Date.now() - lastReported < 15000) return;
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
if (!token) return;
reporting = true;
try {
const response = await fetch('/auth/activity', {
method: 'POST', credentials: 'same-origin', cache: 'no-store',
headers: { 'RequestVerificationToken': token }
});
if (response.ok) lastReported = Date.now();
} catch { /* A failed request never advances the server's idle clock. */ }
finally { reporting = false; }
}
for (const name of ['pointerdown', 'keydown', 'wheel', 'touchstart'])
document.addEventListener(name, recordInput, { passive: true });
@@ -0,0 +1,22 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
/// <summary>Executes one database-only configuration command and its audit rows atomically.</summary>
public interface IConfigurationCommandTransaction
{
/// <summary>Each database retry supplies a fresh scope; the operation must not perform external side effects.</summary>
Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation);
}
/// <summary>Defers local cache/channel maintenance until a configuration commit succeeds.</summary>
public interface IConfigurationCommitActions
{
/// <summary>Queues an idempotent local maintenance action.</summary>
void Add(Func<Task> action);
}
/// <summary>Evicts a circuit's tracked read entities after a separately scoped configuration command.</summary>
public interface IConfigurationReadSession
{
/// <summary>Clears stale tracked read state; form drafts must be held separately.</summary>
void Reset();
}
@@ -2,9 +2,9 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record ListDatabaseConnectionsCommand;
public record GetDatabaseConnectionCommand(int DatabaseConnectionId);
public record CreateDatabaseConnectionDefCommand(string Name, string ConnectionString);
public record CreateDatabaseConnectionDefCommand(string Name, string ConnectionString, int? MaxRetries = null, TimeSpan? RetryDelay = null);
// ConnectionString is nullable (arch-review C3): an omitted (null) value preserves
// the stored secret rather than wiping it — the connection string is elided from
// List/Get responses, so a round-trip must not require re-sending it.
public record UpdateDatabaseConnectionDefCommand(int DatabaseConnectionId, string Name, string? ConnectionString);
public record UpdateDatabaseConnectionDefCommand(int DatabaseConnectionId, string Name, string? ConnectionString, int? MaxRetries = null, TimeSpan? RetryDelay = null);
public record DeleteDatabaseConnectionDefCommand(int DatabaseConnectionId);
@@ -85,3 +85,6 @@ public record NativeAlarmSourceOverrideEntry(
public record SetInstanceNativeAlarmSourceOverridesCommand(
int InstanceId,
IReadOnlyList<NativeAlarmSourceOverrideEntry> Overrides);
/// <summary>Removes an attribute override so the inherited value is used again.</summary>
public record DeleteInstanceAttributeOverrideCommand(int InstanceId, string AttributeName);
@@ -14,4 +14,17 @@ public record UpdateSmtpConfigCommand(int SmtpConfigId, string Server, int Port,
public record ListSmsConfigsCommand;
// FromNumber is optional: a Twilio Messaging-Service-only config supplies MessagingServiceSid
// instead. At-least-one-of (FromNumber, MessagingServiceSid) is validated at the CLI/UI boundary.
public record UpdateSmsConfigCommand(int SmsConfigId, string AccountSid, string? FromNumber, string? MessagingServiceSid = null, string? ApiBaseUrl = null, string? AuthToken = null);
public record UpdateSmsConfigCommand(int SmsConfigId, string AccountSid, string? FromNumber, string? MessagingServiceSid = null, string? ApiBaseUrl = null, string? AuthToken = null, int? ConnectionTimeoutSeconds = null, int? MaxRetries = null, TimeSpan? RetryDelay = null);
/// <summary>Renames a list without replacing its named recipients.</summary>
public record RenameNotificationListCommand(int NotificationListId, string Name);
/// <summary>Adds a named recipient matching the owning list's channel.</summary>
public record AddNotificationRecipientCommand(int NotificationListId, string Name, string? EmailAddress, string? PhoneNumber);
/// <summary>Deletes one recipient without rewriting unrelated list membership.</summary>
public record DeleteNotificationRecipientCommand(int RecipientId);
/// <summary>Creates an email configuration using the update path's validation and secret projection.</summary>
public record CreateSmtpConfigCommand(string Server, int Port, string AuthMode, string FromAddress, string? TlsMode = null, string? Credentials = null, string? OAuth2Authority = null, string? OAuth2Scope = null, string? Transport = null)
: UpdateSmtpConfigCommand(0, Server, Port, AuthMode, FromAddress, TlsMode, Credentials, OAuth2Authority, OAuth2Scope, Transport);
/// <summary>Creates an SMS configuration using the same validation as updates.</summary>
public record CreateSmsConfigCommand(string AccountSid, string? FromNumber, string? MessagingServiceSid = null, string? ApiBaseUrl = null, string? AuthToken = null, int? ConnectionTimeoutSeconds = null, int? MaxRetries = null, TimeSpan? RetryDelay = null)
: UpdateSmsConfigCommand(0, AccountSid, FromNumber, MessagingServiceSid, ApiBaseUrl, AuthToken, ConnectionTimeoutSeconds, MaxRetries, RetryDelay);
@@ -54,7 +54,8 @@ public record NotificationSubmitAck(
/// </summary>
public record NotificationStatusQuery(
string CorrelationId,
string NotificationId);
string NotificationId,
string? VerifiedSourceSiteId = null);
/// <summary>
/// Central -> Site: response carrying the current delivery status for a queried notification.
@@ -30,6 +30,8 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
/// transition time the DCL OBSERVED the ack at (MxAccess Gateway). Non-null only while the
/// condition is active AND acknowledged, so a re-raise (which arrives unacknowledged) clears it.
/// </param>
/// <param name="SnapshotSourceReference">Exact feed binding for a scoped snapshot, including empty for the OPC UA Server feed; null means connection-wide legacy framing.</param>
/// <param name="SnapshotId">Refresh epoch shared by start, entries and end; null preserves legacy snapshot framing.</param>
public record NativeAlarmTransition(
string SourceReference,
string SourceObjectReference,
@@ -45,4 +47,6 @@ public record NativeAlarmTransition(
DateTimeOffset TransitionTime,
string CurrentValue,
string LimitValue,
DateTimeOffset? AckTime = null);
DateTimeOffset? AckTime = null,
string? SnapshotSourceReference = null,
Guid? SnapshotId = null);
@@ -14,5 +14,7 @@ public enum AlarmTransitionKind
Acknowledge,
Clear,
Retrigger,
StateChange
StateChange,
/// <summary>Begins a source-scoped refresh epoch; appended to preserve existing wire values.</summary>
SnapshotStart
}
@@ -0,0 +1,30 @@
using Google.Protobuf;
using Google.Protobuf.Collections;
using Grpc.Core;
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// <summary>Bounds audit batches using complete protobuf row sizes, length/tag prefixes, and gRPC framing.</summary>
public static class AuditWireBudget
{
/// <summary>Five-byte gRPC frame plus two bytes reserved for a true MoreAvailable field.</summary>
public const int InitialBytes = 7;
/// <summary>
/// Appends one row to a field-1 repeated-message collection if it fits. A row larger than the
/// normal budget travels alone, up to the hard channel ceiling. Oversize rows are retained
/// locally and fail explicitly with their identity; they are never acknowledged or truncated.
/// </summary>
public static bool TryAppend<T>(RepeatedField<T> rows, T row, ref int wireBytes, string rowIdentity)
where T : IMessage<T>
{
var rowBytes = 1L + CodedOutputStream.ComputeMessageSize(row);
if (rows.Count > 0 && wireBytes + rowBytes > GrpcMessageLimits.AuditBatchBytes) return false;
if (rowBytes + InitialBytes > GrpcMessageLimits.MaxMessageBytes)
throw new RpcException(new Status(StatusCode.ResourceExhausted,
$"Audit row {rowIdentity} requires {rowBytes + InitialBytes} wire bytes, exceeding {GrpcMessageLimits.MaxMessageBytes}; retained locally. Reduce its payload or raise coordinated peer limits before retrying."));
rows.Add(row);
wireBytes += (int)rowBytes;
return true;
}
}
@@ -117,7 +117,7 @@ public sealed class CentralChannelProvider : IDisposable
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
EnableMultipleHttp2Connections = true,
},
}.WithSiteCredentials(pskProvider, siteId);
}.WithMessageLimits().WithSiteCredentials(pskProvider, siteId);
_channels[i] = GrpcChannel.ForAddress(endpoints[i], channelOptions);
_clients[i] = new CentralControlService.CentralControlServiceClient(_channels[i]);
@@ -56,8 +56,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// to the peer central node only when the call provably never ran. So:
/// </para>
/// <list type="bullet">
/// <item><see cref="StatusCode.Unavailable"/> — this node is not ready; nothing was dispatched,
/// so a cross-node retry is safe and correct.</item>
/// <item><see cref="StatusCode.Unavailable"/> plus the server-owned not-started trailer — this
/// node is not ready; nothing was dispatched, so a cross-node retry is safe. Unavailable alone
/// can also be generated by a transport after application and does not prove retry safety.</item>
/// <item><see cref="StatusCode.DeadlineExceeded"/> — the <c>Ask</c> timed out. The message WAS
/// delivered and may have been processed; retrying it on the other node would duplicate work.</item>
/// <item><see cref="StatusCode.Internal"/> — the handler faulted (a piped
@@ -170,7 +171,7 @@ public sealed class CentralControlGrpcService : CentralControlService.CentralCon
var central = RequireReady(context);
var response = await AskAsync<NotificationStatusResponse>(
central,
CentralControlDtoMapper.FromDto(request),
CentralControlDtoMapper.FromDto(request) with { VerifiedSourceSiteId = CentralControlSiteIdentity.RequireVerified(context) },
_options.NotificationForwardTimeout,
context).ConfigureAwait(false);
@@ -332,9 +333,7 @@ public sealed class CentralControlGrpcService : CentralControlService.CentralCon
+ "the peer central node).",
context.Method);
throw new RpcException(new GrpcStatus(
StatusCode.Unavailable,
"Central control plane is not ready: the actor system is still starting."));
throw GrpcRetrySafety.NotDispatched("Central control plane is not ready: the actor system is still starting.");
}
/// <summary>
@@ -0,0 +1,50 @@
using Grpc.Core;
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// <summary>Verified site identity established by the central PSK interceptor, never by a request body.</summary>
public static class CentralControlSiteIdentity
{
private static readonly object IdentityKey = new();
/// <summary>Stores an identity only after the site's credential has been verified.</summary>
public static void SetVerified(ServerCallContext context, string siteId) => context.UserState[IdentityKey] = siteId;
/// <summary>Requires a credential-verified identity on the current call.</summary>
public static string RequireVerified(ServerCallContext context) =>
context.UserState.TryGetValue(IdentityKey, out var value) && value is string site && !string.IsNullOrWhiteSpace(site)
? site : throw Denied();
/// <summary>Checks every ownership field before dispatching any part of a central control request.</summary>
public static void ValidateRequest(object request, ServerCallContext context)
{
var site = RequireVerified(context);
void Owns(string? owner)
{
if (!string.Equals(site, owner, StringComparison.Ordinal)) throw Denied();
}
switch (request)
{
case NotificationSubmitDto n: Owns(n.SourceSiteId); break;
case ReconcileSiteRequestDto n: Owns(n.SiteIdentifier); break;
case SiteHealthReportDto n: Owns(n.SiteId); break;
case HeartbeatDto n: Owns(n.SiteId); break;
case AuditEventBatch batch:
foreach (var row in batch.Events) Owns(row.SourceSiteId);
break;
case CachedTelemetryBatch batch:
foreach (var packet in batch.Packets)
{
Owns(packet.AuditEvent?.SourceSiteId);
Owns(packet.Operational?.SourceSite);
}
break;
// The query carries no claimed owner. The service passes the verified identity to
// the outbox, which compares it with the stored notification's SourceSiteId.
case NotificationStatusQueryDto: break;
case Google.Protobuf.IMessage: throw Denied(); // new RPCs must explicitly choose an ownership rule
}
}
private static RpcException Denied() => new(new Status(StatusCode.PermissionDenied, "Control-plane site ownership denied."));
}
@@ -26,11 +26,10 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// layers already treat as transient.
/// </para>
/// <para>
/// <b>Cross-node retry only on provably-unsent failures.</b> An <see cref="StatusCode.Unavailable"/>
/// (connection refused / node not ready) flips the channel pair and retries once on the peer.
/// A <see cref="StatusCode.DeadlineExceeded"/> is NEVER retried across nodes — a deploy / write /
/// failover may already have executed, and duplicating it is worse than surfacing a transient
/// failure the layer above tolerates.
/// <b>Cross-node retry is operation-specific.</b> Read-only status and idempotent notification,
/// audit and sequence-guarded health ingestion may retry transient transport failures. Reconcile
/// mints tokens and retries only an explicit server pre-dispatch rejection. Ambiguous reconcile
/// failures return OUTCOME_UNKNOWN. Both attempts share one absolute deadline.
/// </para>
/// <para>
/// <b>Per-call deadlines mirror today's Ask timeouts.</b> Notification submit/status →
@@ -71,7 +70,7 @@ public sealed class GrpcCentralTransport : ICentralTransport
var dto = CentralControlDtoMapper.ToDto(message);
Dispatch(replyTo, _options.NotificationForwardTimeout,
(c, o) => c.SubmitNotificationAsync(dto, o),
ack => CentralControlDtoMapper.FromDto(ack));
ack => CentralControlDtoMapper.FromDto(ack), retrySafe: true);
}
/// <inheritdoc />
@@ -80,7 +79,7 @@ public sealed class GrpcCentralTransport : ICentralTransport
var dto = CentralControlDtoMapper.ToDto(message);
Dispatch(replyTo, _options.NotificationForwardTimeout,
(c, o) => c.QueryNotificationStatusAsync(dto, o),
response => CentralControlDtoMapper.FromDto(response));
response => CentralControlDtoMapper.FromDto(response), retrySafe: true);
}
/// <inheritdoc />
@@ -89,7 +88,7 @@ public sealed class GrpcCentralTransport : ICentralTransport
var batch = CentralControlDtoMapper.ToDto(message);
Dispatch(replyTo, SiteStreamGrpcServer.AuditIngestAskTimeout,
(c, o) => c.IngestAuditEventsAsync(batch, o),
ack => new IngestAuditEventsReply(CentralControlDtoMapper.FromIngestAck(ack)));
ack => new IngestAuditEventsReply(CentralControlDtoMapper.FromIngestAck(ack)), retrySafe: true);
}
/// <inheritdoc />
@@ -98,7 +97,7 @@ public sealed class GrpcCentralTransport : ICentralTransport
var batch = CentralControlDtoMapper.ToDto(message);
Dispatch(replyTo, SiteStreamGrpcServer.AuditIngestAskTimeout,
(c, o) => c.IngestCachedTelemetryAsync(batch, o),
ack => new IngestCachedTelemetryReply(CentralControlDtoMapper.FromIngestAck(ack)));
ack => new IngestCachedTelemetryReply(CentralControlDtoMapper.FromIngestAck(ack)), retrySafe: true);
}
/// <inheritdoc />
@@ -116,7 +115,7 @@ public sealed class GrpcCentralTransport : ICentralTransport
var dto = CentralControlDtoMapper.ToDto(message);
Dispatch(replyTo, _options.HealthReportTimeout,
(c, o) => c.ReportSiteHealthAsync(dto, o),
ack => CentralControlDtoMapper.FromDto(ack));
ack => CentralControlDtoMapper.FromDto(ack), retrySafe: true);
}
/// <inheritdoc />
@@ -156,34 +155,39 @@ public sealed class GrpcCentralTransport : ICentralTransport
IActorRef replyTo,
TimeSpan timeout,
Func<CentralControlService.CentralControlServiceClient, CallOptions, AsyncUnaryCall<TWire>> call,
Func<TWire, object> decode)
Func<TWire, object> decode, bool retrySafe = false)
{
_ = DispatchAsync(replyTo, timeout, call, decode);
_ = DispatchAsync(replyTo, timeout, call, decode, retrySafe);
}
private async Task DispatchAsync<TWire>(
IActorRef replyTo,
TimeSpan timeout,
Func<CentralControlService.CentralControlServiceClient, CallOptions, AsyncUnaryCall<TWire>> call,
Func<TWire, object> decode)
Func<TWire, object> decode, bool retrySafe = false)
{
var (index, client) = _channels.Current();
var deadline = DateTime.UtcNow.Add(timeout);
try
{
var reply = await InvokeAsync(client, timeout, call).ConfigureAwait(false);
var reply = await InvokeAsync(client, deadline, call).ConfigureAwait(false);
replyTo.Tell(decode(reply));
}
catch (RpcException ex) when (IsConnectFailure(ex))
{
// Provably unsent: the connection was refused / the node was not ready. Fail over
// to the peer and retry ONCE. This is the only status we retry across nodes.
_channels.ReportUnavailable(index);
if (!retrySafe && !GrpcRetrySafety.WasNotDispatched(ex))
{
replyTo.Tell(new AkkaStatus.Failure(GrpcRetrySafety.OutcomeUnknown(ex)));
return;
}
// Retry only a read/idempotent ingest or an explicit server pre-dispatch rejection.
var (retryIndex, retryClient) = _channels.Current();
if (retryIndex != index)
{
try
{
var reply = await InvokeAsync(retryClient, timeout, call).ConfigureAwait(false);
var reply = await InvokeAsync(retryClient, deadline, call).ConfigureAwait(false);
replyTo.Tell(decode(reply));
return;
}
@@ -191,7 +195,10 @@ public sealed class GrpcCentralTransport : ICentralTransport
{
_logger.LogWarning(retryEx,
"Central control-plane call failed on both endpoints; surfacing as transient.");
replyTo.Tell(new AkkaStatus.Failure(retryEx));
var reported = retryEx is RpcException rpc && IsConnectFailure(rpc)
&& !retrySafe && !GrpcRetrySafety.WasNotDispatched(rpc)
? GrpcRetrySafety.OutcomeUnknown(rpc) : retryEx;
replyTo.Tell(new AkkaStatus.Failure(reported));
return;
}
}
@@ -209,27 +216,18 @@ public sealed class GrpcCentralTransport : ICentralTransport
private static async Task<TWire> InvokeAsync<TWire>(
CentralControlService.CentralControlServiceClient client,
TimeSpan timeout,
DateTime deadline,
Func<CentralControlService.CentralControlServiceClient, CallOptions, AsyncUnaryCall<TWire>> call)
{
var options = new CallOptions(deadline: DateTime.UtcNow.Add(timeout));
var options = new CallOptions(deadline: deadline);
using var asyncCall = call(client, options);
return await asyncCall.ResponseAsync.ConfigureAwait(false);
}
/// <summary>
/// A failure that provably never reached a server — the only class safe to retry on the peer.
/// <see cref="StatusCode.DeadlineExceeded"/> is deliberately excluded (the call may have run).
/// Identifies transient connection failures for endpoint selection. This does NOT prove
/// that a request was unsent; the caller separately checks operation replay safety.
/// </summary>
/// <remarks>
/// Two shapes qualify: a server-signalled <see cref="StatusCode.Unavailable"/> (e.g. a node
/// that returns Unavailable while it is still starting), and a client-side failure to even
/// start the call — Grpc.Net surfaces a refused/failed connection as
/// <see cref="StatusCode.Internal"/> "Error starting gRPC call" with the transport exception
/// attached, and there the request never left the client. Anything else — including a deadline,
/// a permission denial, or a generic server-side Internal after the call reached the server —
/// is NOT retried across nodes.
/// </remarks>
private static bool IsConnectFailure(RpcException ex)
{
if (ex.StatusCode == StatusCode.Unavailable)
@@ -237,8 +235,8 @@ public sealed class GrpcCentralTransport : ICentralTransport
return true;
}
// "Error starting gRPC call" is Grpc.Net's marker for a call that could not be sent — a
// refused/failed connection carrying an HttpRequestException. Provably unsent.
// A transport-originated Internal can also select a new endpoint; it provides no
// permission to repeat a non-idempotent operation.
return ex.StatusCode == StatusCode.Internal
&& (ex.Status.DebugException is HttpRequestException
|| ex.Status.Detail.StartsWith("Error starting gRPC call", StringComparison.Ordinal));
@@ -0,0 +1,23 @@
using Grpc.Net.Client;
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// <summary>Shared wire ceilings for both ends of the control plane.</summary>
public static class GrpcMessageLimits
{
/// <summary>Maximum serialized message size, including all fields and protobuf framing.</summary>
public const int MaxMessageBytes = 32 * 1024 * 1024;
/// <summary>Normal audit batch budget, leaving ample room below the wire ceiling.</summary>
public const int AuditBatchBytes = 2 * 1024 * 1024;
/// <summary>Applies the control-plane limits to a client channel.</summary>
/// <param name="options">Channel options, including the caller's handler and credentials.</param>
/// <returns>The supplied options for chaining.</returns>
public static GrpcChannelOptions WithMessageLimits(this GrpcChannelOptions options)
{
options.MaxReceiveMessageSize = MaxMessageBytes;
options.MaxSendMessageSize = MaxMessageBytes;
return options;
}
}
@@ -0,0 +1,23 @@
using Grpc.Core;
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// <summary>Separates an explicit server rejection before dispatch from a lost response after possible application.</summary>
public static class GrpcRetrySafety
{
/// <summary>Server-owned trailer emitted only before any actor dispatch.</summary>
public const string DispatchTrailer = "scadabridge-dispatch";
/// <summary>Creates a readiness rejection that proves no work was dispatched.</summary>
public static RpcException NotDispatched(string detail) => new(
new Status(StatusCode.Unavailable, detail), new Metadata { { DispatchTrailer, "not-started" } });
/// <summary>True only for an explicit rejection from the server before it dispatched work.</summary>
public static bool WasNotDispatched(RpcException error) => error.StatusCode == StatusCode.Unavailable
&& error.Trailers.Any(e => e.Key == DispatchTrailer && e.Value == "not-started");
/// <summary>Reports an ambiguous mutation without inviting a blind replay.</summary>
public static RpcException OutcomeUnknown(RpcException error) => new(
new Status(StatusCode.Aborted, "OUTCOME_UNKNOWN: connection failed after the command may have been applied. Verify its state before retrying."),
error.Trailers);
}
@@ -28,8 +28,9 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
/// path, which the S&amp;F/audit layers already treat as transient.
/// </para>
/// <para>
/// <b>Cross-node retry is the channel provider's job</b> and happens only on
/// <c>Unavailable</c> — never on <c>DeadlineExceeded</c> (a write/deploy/failover may have run).
/// <b>Cross-node retry is operation-specific.</b> Read-only commands can retry Unavailable;
/// mutations require an explicit server rejection before dispatch. A lost mutation response
/// returns OUTCOME_UNKNOWN, never an automatic duplicate. Deadlines are not replayed.
/// </para>
/// </remarks>
public sealed class GrpcSiteTransport : ISiteCommandTransport
@@ -125,9 +126,15 @@ public sealed class GrpcSiteTransport : ISiteCommandTransport
return _channels.ExecuteAsync(
envelope.SiteId,
(channel, callCt) => InvokeAsync(channel, group, message, deadline, callCt),
ct);
ct, retrySafe: IsRetrySafe(message));
}
/// <summary>Only read-only commands can replay after a response may have been lost.</summary>
internal static bool IsRetrySafe(object message) => message is
DeploymentStateQueryRequest or BrowseNodeCommand or SearchAddressSpaceCommand or ReadTagValuesCommand
or ListServerCertsCommand or EventLogQueryRequest or DebugSnapshotRequest or ParkedMessageQueryRequest
or RouteToGetAttributesRequest;
private static async Task<object> InvokeAsync(
GrpcChannel channel, SiteCommandGroup group, object message, DateTime deadline, CancellationToken ct)
{
@@ -196,8 +196,7 @@ public sealed class SiteCommandGrpcService : SiteCommandService.SiteCommandServi
var dispatcher = _dispatcher;
if (!_ready || dispatcher is null)
{
throw new RpcException(new GrpcStatus(
StatusCode.Unavailable, "Site command plane not ready."));
throw GrpcRetrySafety.NotDispatched("Site command plane not ready.");
}
return dispatcher;
}
@@ -31,13 +31,10 @@ public sealed class SiteChannelUnavailableException(string siteId)
/// </summary>
/// <remarks>
/// <para>
/// <b>Retry safety.</b> <see cref="ExecuteAsync{T}"/> retries on the other node ONLY when the first
/// attempt failed with <see cref="StatusCode.Unavailable"/> (provably never reached a server —
/// connect refused or readiness-rejected before response headers). A
/// <see cref="StatusCode.DeadlineExceeded"/>, or any other status, is rethrown WITHOUT a cross-node
/// retry: a <c>WriteTag</c>/<c>DeployArtifacts</c>/<c>TriggerSiteFailover</c> may already have
/// executed, and the layers above tolerate the one-shot ambiguity exactly as ClusterClient's Ask
/// did — the migration must not turn one write into two.
/// <b>Retry safety.</b> Unavailable can mean a lost response after application. Only explicitly
/// retry-safe operations or a server-owned not-started trailer permit replay on the peer.
/// Ambiguous mutations fail with Aborted / OUTCOME_UNKNOWN; the next independent call uses the
/// peer. Deadlines are never replayed. Readiness rejection remains safe before actor dispatch.
/// </para>
/// <para>
/// <b>Addresses.</b> Fed by <see cref="UpdateSite"/>/<see cref="RemoveSite"/> from the single DB
@@ -188,17 +185,18 @@ public sealed class SitePairChannelProvider : IDisposable
/// <summary>
/// Runs <paramref name="call"/> against the site's current sticky channel, failing over to the
/// other node once on <see cref="StatusCode.Unavailable"/> (never on
/// <see cref="StatusCode.DeadlineExceeded"/>). Ensures the background failback loop is running.
/// other node only for a retry-safe operation or proven pre-dispatch rejection. An ambiguous
/// mutation returns OUTCOME_UNKNOWN. Ensures the background failback loop is running.
/// </summary>
/// <typeparam name="T">The RPC reply type.</typeparam>
/// <param name="siteId">Target site.</param>
/// <param name="call">The RPC to run against a given channel.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="retrySafe">Whether repeating this operation is safe after an ambiguous transport failure.</param>
/// <returns>The RPC reply.</returns>
/// <exception cref="SiteChannelUnavailableException">The site has no configured channel.</exception>
public async Task<T> ExecuteAsync<T>(
string siteId, Func<GrpcChannel, CancellationToken, Task<T>> call, CancellationToken ct)
string siteId, Func<GrpcChannel, CancellationToken, Task<T>> call, CancellationToken ct, bool retrySafe = false)
{
ArgumentException.ThrowIfNullOrWhiteSpace(siteId);
ArgumentNullException.ThrowIfNull(call);
@@ -227,14 +225,22 @@ public sealed class SitePairChannelProvider : IDisposable
{
return await call(primary, ct).ConfigureAwait(false);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable && secondary is not null)
catch (RpcException ex) when (ex.StatusCode == StatusCode.Unavailable)
{
// Provably never reached a server → safe to try the other node, even for a write.
if (secondary is not null) FlipTo(pair, toIsA: !primaryIsA);
if (!retrySafe && !GrpcRetrySafety.WasNotDispatched(ex))
throw GrpcRetrySafety.OutcomeUnknown(ex);
if (secondary is null) throw;
// Only reads/idempotent operations or an explicit pre-dispatch rejection may replay.
_logger.LogWarning(
"Site {SiteId} node {FromNode} unavailable; failing over to node {ToNode}",
siteId, primaryIsA ? "A" : "B", primaryIsA ? "B" : "A");
FlipTo(pair, toIsA: !primaryIsA);
return await call(secondary, ct).ConfigureAwait(false);
try { return await call(secondary, ct).ConfigureAwait(false); }
catch (RpcException retryError) when (retryError.StatusCode == StatusCode.Unavailable
&& !retrySafe && !GrpcRetrySafety.WasNotDispatched(retryError))
{
throw GrpcRetrySafety.OutcomeUnknown(retryError);
}
}
}
@@ -432,7 +438,7 @@ public sealed class SitePairChannelProvider : IDisposable
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always,
EnableMultipleHttp2Connections = true
}
}.WithSiteCredentials(_pskProvider, siteId);
}.WithMessageLimits().WithSiteCredentials(_pskProvider, siteId);
return GrpcChannel.ForAddress(endpoint, channelOptions);
}
@@ -93,7 +93,7 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
KeepAlivePingTimeout = options.GrpcKeepAlivePingTimeout,
KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always
}
}.WithSiteCredentials(pskProvider, siteIdentifier));
}.WithMessageLimits().WithSiteCredentials(pskProvider, siteIdentifier));
_client = new SiteStreamService.SiteStreamServiceClient(_channel);
_logger = logger;
}
@@ -792,9 +792,14 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
// (plus, once it sets after_id, that row's EventId).
MoreAvailable = events.Count >= request.BatchSize,
};
var wireBytes = AuditWireBudget.InitialBytes;
foreach (var evt in events)
{
response.Events.Add(AuditEventDtoMapper.ToDto(evt));
if (!AuditWireBudget.TryAppend(response.Events, AuditEventDtoMapper.ToDto(evt), ref wireBytes, evt.EventId.ToString()))
{
response.MoreAvailable = true;
break;
}
}
return response;
@@ -860,9 +865,14 @@ public class SiteStreamGrpcServer : SiteStreamService.SiteStreamServiceBase
// is upsert-on-newer, so re-reading rows is a harmless no-op.
MoreAvailable = operationals.Count >= request.BatchSize,
};
var wireBytes = AuditWireBudget.InitialBytes;
foreach (var op in operationals)
{
response.Operationals.Add(SiteCallDtoMapper.ToDto(op));
if (!AuditWireBudget.TryAppend(response.Operationals, SiteCallDtoMapper.ToDto(op), ref wireBytes, op.TrackedOperationId.ToString()))
{
response.MoreAvailable = true;
break;
}
}
return response;
@@ -181,8 +181,16 @@ VALUES
@LastError, @HttpStatus, @CreatedAtUtc, @UpdatedAtUtc, @TerminalAtUtc, @IngestedAtUtc);";
// Leg 2 of the upsert: the monotonic guard. Also re-run standalone from the
// duplicate-key catch, which is why it lives in its own constant.
// duplicate-key catch, which is why it lives in its own constant. Ownership
// is checked here as well as in the UPDATE predicate: an insert-race loser
// must not advance another site's operation. Throwing rolls back the paired
// audit/operational ingest transaction instead of acknowledging forged state.
private const string MonotonicUpdateSql = @"
IF EXISTS (SELECT 1 FROM dbo.SiteCalls
WHERE TrackedOperationId = @Id
AND SourceSite COLLATE Latin1_General_100_BIN2 <> @SourceSite COLLATE Latin1_General_100_BIN2)
THROW 51001, 'Site-call operation ownership denied.', 1;
UPDATE dbo.SiteCalls
SET Status = @Status,
RetryCount = @RetryCount,
@@ -193,6 +201,7 @@ SET Status = @Status,
IngestedAtUtc = @IngestedAtUtc,
SourceNode = COALESCE(@SourceNode, SourceNode)
WHERE TrackedOperationId = @Id
AND SourceSite COLLATE Latin1_General_100_BIN2 = @SourceSite COLLATE Latin1_General_100_BIN2
AND ( @Rank > (CASE Status
WHEN 'Submitted' THEN 0
WHEN 'Forwarded' THEN 1
@@ -74,6 +74,10 @@ public static class ServiceCollectionExtensions
// registration of the same interface is idempotent.
services.TryAddSingleton<ITemplateGraphWatermark, TemplateGraphWatermark>();
services.AddSingleton<IConfigurationCommandTransaction, ConfigurationCommandTransaction>();
services.AddScoped<ConfigurationCommitActions>();
services.AddScoped<IConfigurationCommitActions>(sp => sp.GetRequiredService<ConfigurationCommitActions>());
services.AddScoped<IConfigurationReadSession, ConfigurationReadSession>();
services.AddScoped<ISecurityRepository, SecurityRepository>();
services.AddScoped<ICentralUiRepository, CentralUiRepository>();
services.AddScoped<ITemplateEngineRepository, TemplateEngineRepository>();
@@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;
/// <summary>Owns the SQL transaction spanning configuration saves and the final audit flush.</summary>
public sealed class ConfigurationCommandTransaction(IServiceScopeFactory scopes) : IConfigurationCommandTransaction
{
/// <inheritdoc />
public async Task<object?> ExecuteAsync(Func<IServiceProvider, Task<object?>> operation)
{
using var strategyScope = scopes.CreateScope();
var strategy = strategyScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>().Database.CreateExecutionStrategy();
var commitStarted = false;
var result = await strategy.ExecuteAsync(async () =>
{
// A lost commit acknowledgment must never automatically replay a create.
if (commitStarted) throw new InvalidOperationException("Configuration commit outcome is uncertain; inspect the configuration audit before retrying.");
await using var attempt = scopes.CreateAsyncScope();
var db = attempt.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
await using var transaction = await db.Database.BeginTransactionAsync();
var value = await operation(attempt.ServiceProvider);
await db.SaveChangesAsync(); // AuditService stages rows; flush them inside this transaction.
commitStarted = true;
await transaction.CommitAsync();
await attempt.ServiceProvider.GetRequiredService<ConfigurationCommitActions>().RunAsync();
return value;
});
return result;
}
}
/// <summary>Per-attempt postcommit actions; rolled-back attempts never run them.</summary>
public sealed class ConfigurationCommitActions(ILogger<ConfigurationCommitActions> logger) : IConfigurationCommitActions
{
private readonly List<Func<Task>> _actions = [];
/// <inheritdoc />
public void Add(Func<Task> action) => _actions.Add(action);
internal async Task RunAsync()
{
foreach (var action in _actions)
{
try { await action(); }
catch (Exception ex) { logger.LogError(ex, "Configuration committed, but local cache maintenance failed"); }
}
}
}
/// <summary>Refreshes circuit-held read entities after a command owned by another scope.</summary>
public sealed class ConfigurationReadSession(ScadaBridgeDbContext db) : IConfigurationReadSession
{
/// <inheritdoc />
public void Reset() => db.ChangeTracker.Clear();
}
@@ -12,12 +12,12 @@ using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
/// <summary>
/// Connection actor using Akka.NET Become/Stash pattern for lifecycle state machine.
/// Connection actor using Akka.NET Become for its lifecycle state machine.
///
/// States:
/// - Connecting: stash subscribe/write requests; attempts connection
/// - Connected: unstash and process all requests
/// - Reconnecting: push bad quality for all subscribed tags, stash new requests,
/// - Connecting: retain latest subscription requests, reject writes; attempts connection
/// - Connected: process pending subscriptions and live requests
/// - Reconnecting: push bad quality, retain latest subscriptions and reject writes,
/// fixed-interval reconnect
///
/// Auto-reconnect with bad quality on disconnect.
@@ -27,7 +27,7 @@ namespace ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Actors;
/// Health reporting (connection status + tag resolution counts).
/// Subscription lifecycle (register on create, cleanup on stop).
/// </summary>
public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
public class DataConnectionActor : UntypedActor, IWithTimers
{
public enum ActiveEndpoint { Primary, Backup }
@@ -45,8 +45,6 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
private string _protocolType;
private readonly ISiteEventLogger? _siteEventLogger;
/// <summary>Stash for holding messages while the connection is not in the Connected state.</summary>
public IStash Stash { get; set; } = null!;
/// <summary>Timer scheduler for reconnect and tag-resolution retry timers.</summary>
public ITimerScheduler Timers { get; set; } = null!;
@@ -413,13 +411,23 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// declaring the same dead adapter Connected anyway.
NotePendingDisconnect(disconnected);
break;
case SubscribeTagsRequest:
case WriteTagRequest:
case WriteTagBatchRequest:
case UnsubscribeTagsRequest:
case SubscribeAlarmsRequest:
case UnsubscribeAlarmsRequest:
Stash.Stash();
case SubscribeTagsRequest pending:
QueueSubscribe(pending);
break;
case SubscribeAlarmsRequest pendingAlarm:
QueueSubscribeAlarms(pendingAlarm);
break;
case WriteTagRequest write:
RejectDisconnectedWrite(write);
break;
case WriteTagBatchRequest batch:
RejectDisconnectedWrite(batch);
break;
case UnsubscribeTagsRequest unsubscribe:
HandleUnsubscribe(unsubscribe);
break;
case UnsubscribeAlarmsRequest unsubscribeAlarm:
HandleUnsubscribeAlarms(unsubscribeAlarm);
break;
case ReconfigureConnectionCommand rc:
// NEVER stashed. A reconfigure that arrived while the initial connect was still
@@ -469,6 +477,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// ConnectionNotConnected.
HandleReadTagValues(read);
break;
case Terminated terminated:
HandleSubscriberTerminated(terminated.ActorRef);
break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -491,7 +502,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// Register row 54 — the only transition that raises the connected gauge.
ScadaBridgeTelemetry.RecordDataConnectionState(_connectionName, ConnectionHealth.Connected);
Become(Connected);
Stash.UnstashAll();
foreach (var pending in _pendingTagSubscriptions.Values)
Self.Tell(pending.Request, pending.Owner);
_pendingTagSubscriptions.Clear();
foreach (var (key, request) in _pendingAlarmSubscriptions)
Self.Tell(request, key.Owner);
_pendingAlarmSubscriptions.Clear();
}
private void Connected(object message)
@@ -563,6 +579,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
case ReadTagValuesCommand read:
HandleReadTagValues(read);
break;
case Terminated terminated:
HandleSubscriberTerminated(terminated.ActorRef);
break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -789,11 +808,17 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// adapter that will never raise Disconnected a second time.
NotePendingDisconnect(disconnected);
break;
case SubscribeTagsRequest:
case WriteTagRequest:
case WriteTagBatchRequest:
case SubscribeAlarmsRequest:
Stash.Stash();
case SubscribeTagsRequest pending:
QueueSubscribe(pending);
break;
case SubscribeAlarmsRequest pendingAlarm:
QueueSubscribeAlarms(pendingAlarm);
break;
case WriteTagRequest write:
RejectDisconnectedWrite(write);
break;
case WriteTagBatchRequest batch:
RejectDisconnectedWrite(batch);
break;
case UnsubscribeTagsRequest req:
// Allow unsubscribe even during reconnect (for cleanup on instance stop)
@@ -850,6 +875,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// to a ConnectionNotConnected failure.
HandleReadTagValues(read);
break;
case Terminated terminated:
HandleSubscriberTerminated(terminated.ActorRef);
break;
case GetHealthReport:
ReplyWithHealthReport();
break;
@@ -1110,6 +1138,52 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
BecomeReconnecting();
}
// One latest request per owner/source while disconnected; retries cannot grow a stash.
private readonly Dictionary<string, (SubscribeTagsRequest Request, IActorRef Owner)> _pendingTagSubscriptions = new();
private readonly Dictionary<(IActorRef Owner, string Source), SubscribeAlarmsRequest> _pendingAlarmSubscriptions = new();
private void QueueSubscribe(SubscribeTagsRequest request)
{
Context.Watch(Sender);
_pendingTagSubscriptions.TryGetValue(request.InstanceUniqueName, out var previous);
_pendingTagSubscriptions[request.InstanceUniqueName] = (request, Sender);
if (previous.Owner is not null && !previous.Owner.Equals(Sender))
UnwatchIfUnused(previous.Owner);
}
private void QueueSubscribeAlarms(SubscribeAlarmsRequest request)
{
Context.Watch(Sender);
_pendingAlarmSubscriptions[(Sender, request.SourceReference)] = request;
}
private void RejectDisconnectedWrite(WriteTagRequest request) => Sender.Tell(new WriteTagResponse(
request.CorrelationId, false, "Connection is not connected; device write was not queued.", DateTimeOffset.UtcNow));
private void RejectDisconnectedWrite(WriteTagBatchRequest request) => Sender.Tell(new WriteTagBatchResponse(
request.CorrelationId, false, "Connection is not connected; device write was not queued.", DateTimeOffset.UtcNow));
private void UnwatchIfUnused(IActorRef owner)
{
if (!_subscribers.Values.Contains(owner)
&& !_alarmSourceSubscribers.Values.Any(subs => subs.Contains(owner))
&& !_pendingTagSubscriptions.Values.Any(p => p.Owner.Equals(owner))
&& !_pendingAlarmSubscriptions.Keys.Any(k => k.Owner.Equals(owner)))
Context.Unwatch(owner);
}
private void HandleSubscriberTerminated(IActorRef owner)
{
foreach (var instance in _pendingTagSubscriptions.Where(kv => kv.Value.Owner.Equals(owner)).Select(kv => kv.Key).ToList())
_pendingTagSubscriptions.Remove(instance);
foreach (var key in _pendingAlarmSubscriptions.Keys.Where(k => k.Owner.Equals(owner)).ToList())
_pendingAlarmSubscriptions.Remove(key);
foreach (var instance in _subscribers.Where(kv => kv.Value.Equals(owner)).Select(kv => kv.Key).ToList())
RemoveTagSubscriber(instance, owner);
foreach (var source in _alarmSourceSubscribers.Where(kv => kv.Value.Contains(owner)).Select(kv => kv.Key).ToList())
RemoveAlarmSubscriber(source, owner);
}
// ── Subscription Management ──
private void HandleSubscribe(SubscribeTagsRequest request)
@@ -1117,10 +1191,21 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
_log.Debug("[{0}] Subscribing {1} tags for instance {2}",
_connectionName, request.TagPaths.Count, request.InstanceUniqueName);
Context.Watch(Sender);
if (_subscribers.TryGetValue(request.InstanceUniqueName, out var previousOwner) && !previousOwner.Equals(Sender))
RemoveTagSubscriber(request.InstanceUniqueName, previousOwner, request.TagPaths.ToHashSet());
_subscribers[request.InstanceUniqueName] = Sender;
if (previousOwner is not null && !previousOwner.Equals(Sender))
UnwatchIfUnused(previousOwner);
if (!_subscriptionsByInstance.ContainsKey(request.InstanceUniqueName))
_subscriptionsByInstance[request.InstanceUniqueName] = new HashSet<string>();
if (!_subscriptionsByInstance.TryGetValue(request.InstanceUniqueName, out var requestedTags))
_subscriptionsByInstance[request.InstanceUniqueName] = requestedTags = new HashSet<string>();
// Record demand before I/O, so a replacement owner can adopt a shared in-flight result.
foreach (var tag in request.TagPaths)
{
requestedTags.Add(tag);
IndexTag(tag, request.InstanceUniqueName);
}
var self = Self;
var sender = Sender;
@@ -1148,7 +1233,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
var preResolvedResults = new List<SubscribeTagResult>();
foreach (var tagPath in request.TagPaths)
{
if (_subscriptionIds.ContainsKey(tagPath) || _subscribesInFlight.Contains(tagPath))
if (_subscriptionIds.ContainsKey(tagPath) || _subscribesInFlight.Contains(tagPath) || _resolutionInFlight.Contains(tagPath))
{
preResolvedResults.Add(new SubscribeTagResult(
tagPath, AlreadySubscribed: true, Success: true, null, null));
@@ -1540,10 +1625,9 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// wholesale once the replacement adapter connects, and a marker the new generation
// has since placed for the same tag must not be removed by this dead message.
// Reply failure so the requester re-sends. Nothing has registered this request's
// tags in _subscriptionsByInstance (registration happens below, on the applied
// path), so ReSubscribeAll cannot pick them up — the InstanceActor's per-connection
// retry timer, armed until a Success arrives, is what re-establishes them.
// Demand was registered before I/O, so ReSubscribeAll can recover it on the
// replacement adapter. Reply failure as well so the owner's correlated retry
// state never treats this stale completion as an established subscription.
msg.ReplyTo.Tell(new SubscribeTagsResponse(
msg.Request.CorrelationId, instanceName, false,
"adapter replaced while the subscribe was in flight — will retry",
@@ -1551,53 +1635,17 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
return false;
}
if (!_subscriptionsByInstance.TryGetValue(instanceName, out var instanceTags))
if (!_subscribers.TryGetValue(instanceName, out var currentOwner) || !currentOwner.Equals(msg.ReplyTo)
|| !_subscriptionsByInstance.TryGetValue(instanceName, out var instanceTags))
{
// The instance was unsubscribed while the
// subscribe I/O was in flight. Re-creating the per-instance entry and
// applying the handle mutations here would permanently leak state
// — _subscriptionsByInstance[instanceName] resurrected with no
// subscriber to receive callbacks, _instancesByTag re-indexed against
// an instance no future HandleUnsubscribe will ever visit, and the
// health counts derived from both reporting tags nobody subscribes to
// for the rest of the adapter lifetime (also re-issued by ReSubscribeAll
// on every reconnect). Instead: drop all state mutations for this stale
// message and release the adapter-level monitored items we just
// created so the device doesn't keep streaming change notifications
// for a tag nobody is subscribed to.
_log.Warning(
"[{0}] SubscribeCompleted arrived for instance {1} but the instance " +
"was unsubscribed while the subscribe was in flight; releasing " +
"{2} adapter handle(s) and discarding state mutations.",
_connectionName, instanceName, msg.Results.Count(r => r.Success && !r.AlreadySubscribed));
var orphanedIds = new List<string>();
foreach (var result in msg.Results)
{
// Clear in-flight markers we placed in HandleSubscribe.
if (!result.AlreadySubscribed)
_subscribesInFlight.Remove(result.TagPath);
// Fire-and-forget release of any subscription id this request
// genuinely created. AlreadySubscribed=true means another caller
// owns the adapter handle and unsubscribing it would break them.
if (result is { Success: true, AlreadySubscribed: false, SubscriptionId: not null })
{
orphanedIds.Add(result.SubscriptionId);
}
}
// Against the adapter that created them (identical to _adapter here — the
// generation guard above already proved the completion is current — but stating
// the ownership rule at every release site is what keeps it true).
_ = UnsubscribeIdsAsync(msg.Adapter, orphanedIds);
// The original sender is already gone (unsubscribed). Telling a dead
// ref produces a dead letter, which is the harmless and observable
// outcome — but skipping the reply altogether keeps dead-letter noise
// out of the log when this race fires in the normal disable/redeploy
// path. The unsubscribe message did NOT request a response of its own.
return false;
// The initiating owner may be gone, but another instance/replacement can
// still need these tags. Reconcile against CURRENT per-tag demand instead
// of unconditionally releasing handles owned by shared in-flight work.
foreach (var row in msg.Results.Where(r => !r.AlreadySubscribed))
_subscribesInFlight.Remove(row.TagPath);
HandleBatchSubscribeCompleted(new BatchSubscribeCompleted(
msg.Results.Where(r => !r.AlreadySubscribed).ToList(), msg.AdapterGeneration, BatchSubscribeSource.Resubscribe));
return msg.Results.Any(r => r.ConnectionLevelFailure && _instancesByTag.ContainsKey(r.TagPath));
}
// If any tag failed because the adapter is not
@@ -1647,7 +1695,8 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// monitored item forever — _subscriptionIds holds a single id per tag, so
// no later unsubscribe can ever reference this one. Release it, exactly as
// HandleBatchSubscribeCompleted does for its duplicate rows.
if (result is { Success: true, SubscriptionId: not null })
if (result is { Success: true, SubscriptionId: not null }
&& _subscriptionIds[result.TagPath] != result.SubscriptionId)
redundantIds.Add(result.SubscriptionId);
continue;
}
@@ -1798,10 +1847,16 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
private void HandleUnsubscribe(UnsubscribeTagsRequest request)
{
_log.Debug("[{0}] Unsubscribing all tags for instance {1}",
_connectionName, request.InstanceUniqueName);
if (_pendingTagSubscriptions.TryGetValue(request.InstanceUniqueName, out var pending) && pending.Owner.Equals(Sender))
_pendingTagSubscriptions.Remove(request.InstanceUniqueName);
RemoveTagSubscriber(request.InstanceUniqueName, Sender);
UnwatchIfUnused(Sender);
}
if (!_subscriptionsByInstance.TryGetValue(request.InstanceUniqueName, out var tags))
private void RemoveTagSubscriber(string instanceName, IActorRef owner, HashSet<string>? preserveTags = null)
{
if (!_subscribers.TryGetValue(instanceName, out var currentOwner) || !currentOwner.Equals(owner)
|| !_subscriptionsByInstance.TryGetValue(instanceName, out var tags))
return;
// Released adapter handles are collected and released in ONE round trip below
@@ -1809,7 +1864,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
var idsToRelease = new List<string>();
// Cleanup on Instance Actor stop
foreach (var tagPath in tags)
foreach (var tagPath in tags.Where(t => preserveTags == null || !preserveTags.Contains(t)).ToList())
{
// Drop this instance from the per-tag counted set. UnindexTag removes the tag
// key entirely when its last subscriber leaves, so "the key is gone" IS the
@@ -1818,7 +1873,8 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// keys this loop retires, whatever state the tag was in (resolved, unresolved,
// or mid-reconnect with both maps cleared — the case the retired scalar
// counters silently failed to decrement at all).
UnindexTag(tagPath, request.InstanceUniqueName);
tags.Remove(tagPath);
UnindexTag(tagPath, instanceName);
if (_instancesByTag.ContainsKey(tagPath))
continue;
@@ -1851,8 +1907,12 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
_resolutionInFlight.Remove(tagPath);
}
_subscriptionsByInstance.Remove(request.InstanceUniqueName);
_subscribers.Remove(request.InstanceUniqueName);
if (preserveTags is null)
{
_subscriptionsByInstance.Remove(instanceName);
_subscribers.Remove(instanceName);
UnwatchIfUnused(owner);
}
// Fire-and-forget release of every handle this unsubscribe orphaned.
_ = UnsubscribeIdsAsync(_adapter, idsToRelease);
@@ -2274,7 +2334,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// Only dispatch retries for tags that do not already
// have an attempt in flight. A slow subscribe overlapping the next tick
// would otherwise produce duplicate concurrent subscribes for the same tag.
var toResolve = _unresolvedTags.Where(t => !_resolutionInFlight.Contains(t)).ToList();
var toResolve = _unresolvedTags.Where(t => !_resolutionInFlight.Contains(t) && !_subscribesInFlight.Contains(t)).ToList();
if (toResolve.Count == 0)
{
@@ -2506,7 +2566,8 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
// Another path already stored a handle for this tag while this one was
// in flight — release the redundant handle instead of leaking it
// (mirrors the duplicate-alarm-feed guard).
idsToRelease.Add(row.SubscriptionId);
if (_subscriptionIds[row.TagPath] != row.SubscriptionId)
idsToRelease.Add(row.SubscriptionId);
}
else
{
@@ -2860,6 +2921,7 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
IndexAlarmSource(request.SourceReference);
}
subs.Add(subscriber);
Context.Watch(subscriber);
// S10: the adapter feed carries a single shared condition filter per source.
// A second subscriber with a different filter silently overwrites the first
// (last-writer-wins). Surface it so a co-subscriber mismatch is diagnosable —
@@ -3008,19 +3070,20 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
var transition = msg.Transition;
var notified = new HashSet<IActorRef>();
// A SnapshotComplete is a connection-wide framing sentinel, not a real
// condition: the mapper emits it with an empty SourceReference /
// SourceObjectReference. It must reach EVERY alarm subscriber so each
// NativeAlarmActor can atomically swap in the snapshot it just buffered.
// The per-source prefix match below would drop it ("".StartsWith("<src>.")
// is false), which would strand statically-active conditions that are only
// delivered in the snapshot (no later live transition) — they would buffer
// forever and never surface. Broadcast the sentinel to all subscribers,
// bypassing the source match and the condition-type filter (the sentinel
// carries no condition; the buffered entries were already filtered).
if (transition.Kind == AlarmTransitionKind.SnapshotComplete)
// OPC UA refresh boundaries belong to one monitored source, whereas Mx
// uses one connection-wide alarm stream. Boundaries bypass condition filters
// but must retain their scope: broadcasting a second source's end marker
// would clear the first source's already-committed snapshot.
if (transition.Kind is AlarmTransitionKind.SnapshotStart or AlarmTransitionKind.SnapshotComplete)
{
foreach (var subs in _alarmSourceSubscribers.Values)
// Explicit scope distinguishes the empty OPC UA Server binding from the
// connection-wide Mx stream. Legacy empty-scope sentinels stay connection-wide.
var scope = transition.SnapshotSourceReference
?? (string.IsNullOrEmpty(transition.SourceObjectReference) ? null : transition.SourceObjectReference);
var targets = scope is null
? _alarmSourceSubscribers.Values
: _alarmSourceSubscribers.Where(kv => kv.Key == scope).Select(kv => kv.Value);
foreach (var subs in targets)
foreach (var sub in subs)
if (notified.Add(sub))
sub.Tell(new NativeAlarmTransitionUpdate(_connectionName, transition));
@@ -3137,24 +3200,30 @@ public class DataConnectionActor : UntypedActor, IWithStash, IWithTimers
private void HandleUnsubscribeAlarms(UnsubscribeAlarmsRequest request)
{
if (!_alarmSourceSubscribers.TryGetValue(request.SourceReference, out var subs))
return;
_pendingAlarmSubscriptions.Remove((Sender, request.SourceReference));
RemoveAlarmSubscriber(request.SourceReference, Sender);
UnwatchIfUnused(Sender);
}
subs.Remove(Sender);
private void RemoveAlarmSubscriber(string sourceReference, IActorRef owner)
{
if (!_alarmSourceSubscribers.TryGetValue(sourceReference, out var subs) || !subs.Remove(owner))
return;
UnwatchIfUnused(owner);
if (subs.Count > 0)
return;
// No subscribers remain for this source — tear down the adapter feed.
_alarmSourceSubscribers.Remove(request.SourceReference);
UnindexAlarmSource(request.SourceReference);
_alarmSourceFilter.Remove(request.SourceReference);
_alarmSourceFilterPredicate.Remove(request.SourceReference);
_alarmSourceSubscribers.Remove(sourceReference);
UnindexAlarmSource(sourceReference);
_alarmSourceFilter.Remove(sourceReference);
_alarmSourceFilterPredicate.Remove(sourceReference);
// Clear the in-flight marker so that if an adapter
// subscribe is still in flight for this source, the late AlarmSubscribeCompleted
// is recognized as orphaned (its guard checks _alarmSourceSubscribers, now empty)
// and the just-created feed is released rather than stored and leaked.
_alarmSubscribesInFlight.Remove(request.SourceReference);
if (_alarmSubscriptionIds.Remove(request.SourceReference, out var subId) &&
_alarmSubscribesInFlight.Remove(sourceReference);
if (_alarmSubscriptionIds.Remove(sourceReference, out var subId) &&
_adapter is IAlarmSubscribableConnection alarmable)
{
_ = alarmable.UnsubscribeAlarmsAsync(subId);
@@ -263,7 +263,7 @@ public class MxGatewayDataConnection
public async Task UnsubscribeAsync(string subscriptionId, CancellationToken cancellationToken = default)
{
if (_subs.TryRemove(subscriptionId, out var s))
_tagToSub.TryRemove(s.TagPath, out _);
((ICollection<KeyValuePair<string, string>>)_tagToSub).Remove(new(s.TagPath, subscriptionId));
await _client!.UnsubscribeAsync(subscriptionId, cancellationToken);
}
@@ -297,7 +297,7 @@ public class MxGatewayDataConnection
foreach (var id in subscriptionIds)
{
if (_subs.TryRemove(id, out var s))
_tagToSub.TryRemove(s.TagPath, out _);
((ICollection<KeyValuePair<string, string>>)_tagToSub).Remove(new(s.TagPath, id));
}
await _client!.UnsubscribeBulkAsync(subscriptionIds, cancellationToken);
@@ -216,7 +216,7 @@ public sealed class RealMxGatewayClient : IMxGatewayClient
// UnsubscribeBulk unadvises AND removes the item, releasing every advice kind.
_supervisoryAdvised.TryRemove(handle, out _);
if (_handleToTag.TryRemove(handle, out var tag))
_tagToHandle.TryRemove(tag, out _);
((ICollection<KeyValuePair<string, int>>)_tagToHandle).Remove(new(tag, handle));
}
}
@@ -233,7 +233,7 @@ public sealed class RealMxGatewayClient : IMxGatewayClient
// so forget any supervisory-advise state for this handle.
_supervisoryAdvised.TryRemove(handle, out _);
if (_handleToTag.TryRemove(handle, out var tag))
_tagToHandle.TryRemove(tag, out _);
((ICollection<KeyValuePair<string, int>>)_tagToHandle).Remove(new(tag, handle));
}
/// <inheritdoc />
@@ -68,6 +68,7 @@ public class RealOpcUaClient : IOpcUaClient
private readonly ConcurrentDictionary<string, MonitoredItem> _alarmItems = new();
// Per-handle "currently inside a ConditionRefresh replay" flag → Snapshot kind.
private readonly ConcurrentDictionary<string, bool> _alarmInRefresh = new();
private readonly ConcurrentDictionary<string, Guid> _alarmSnapshotIds = new();
// Per-handle last (active, acked) by source reference, to derive transition kind.
private readonly ConcurrentDictionary<string, Dictionary<string, (bool Active, bool Acked)>> _alarmLastState = new();
// Int flag toggled with Interlocked.Exchange so the
@@ -543,6 +544,10 @@ public class RealOpcUaClient : IOpcUaClient
}
_monitoredItems.Clear();
_callbacks.Clear();
_alarmItems.Clear();
_alarmInRefresh.Clear();
_alarmSnapshotIds.Clear();
_alarmLastState.Clear();
}
/// <inheritdoc />
@@ -925,6 +930,7 @@ public class RealOpcUaClient : IOpcUaClient
}, cancellationToken);
}
_alarmInRefresh.TryRemove(subscriptionHandle, out _);
_alarmSnapshotIds.TryRemove(subscriptionHandle, out _);
_alarmLastState.TryRemove(subscriptionHandle, out _);
}
@@ -1160,12 +1166,16 @@ public class RealOpcUaClient : IOpcUaClient
if (eventType == ObjectTypeIds.RefreshStartEventType)
{
_alarmInRefresh[handle] = true;
var startEpoch = Guid.NewGuid();
_alarmSnapshotIds[handle] = startEpoch;
onTransition(SnapshotBoundary(AlarmTransitionKind.SnapshotStart, subscriptionSourceReference, startEpoch));
return;
}
if (eventType == ObjectTypeIds.RefreshEndEventType)
{
_alarmInRefresh[handle] = false;
onTransition(SnapshotComplete());
if (_alarmSnapshotIds.TryRemove(handle, out var endEpoch))
onTransition(SnapshotBoundary(AlarmTransitionKind.SnapshotComplete, subscriptionSourceReference, endEpoch));
return;
}
@@ -1253,13 +1263,16 @@ public class RealOpcUaClient : IOpcUaClient
LimitValue: limitValue,
// MES alarm-status API §6.4: true source ack instant (index 18) when supplied,
// else the observed event time; null unless the condition is active AND acked.
AckTime: OpcUaAlarmMapper.DeriveAckTime(active, acked, sourceAckTime, time)));
AckTime: OpcUaAlarmMapper.DeriveAckTime(active, acked, sourceAckTime, time),
SnapshotSourceReference: inRefresh ? subscriptionSourceReference ?? string.Empty : null,
SnapshotId: inRefresh && _alarmSnapshotIds.TryGetValue(handle, out var epoch) ? epoch : null));
}
private static NativeAlarmTransition SnapshotComplete() => new(
"", "", "", AlarmTransitionKind.SnapshotComplete,
private static NativeAlarmTransition SnapshotBoundary(AlarmTransitionKind kind, string? source, Guid epoch) => new(
"", source ?? string.Empty, "", kind,
new Commons.Types.Alarms.AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0),
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "");
"", "", "", "", "", null, DateTimeOffset.UtcNow, "", "",
SnapshotSourceReference: source ?? string.Empty, SnapshotId: epoch);
/// <summary>
/// Nodes per OPC UA Read/Write service call. Stays under the
@@ -99,6 +99,7 @@ public sealed class CentralControlAuthInterceptor : Interceptor
UnaryServerMethod<TRequest, TResponse> continuation)
{
await AuthorizeAsync(context).ConfigureAwait(false);
if (IsGated(context.Method)) CentralControlSiteIdentity.ValidateRequest(request, context);
return await continuation(request, context).ConfigureAwait(false);
}
@@ -187,6 +188,8 @@ public sealed class CentralControlAuthInterceptor : Interceptor
presented is null ? "no bearer token presented" : "bearer token did not match");
throw Denied("control plane authentication failed");
}
CentralControlSiteIdentity.SetVerified(context, siteId);
}
private static RpcException Denied(string reason)
@@ -290,6 +290,8 @@ try
// removed ClusterClient path used to carry, and Asks CentralCommunicationActor — zero handler logic here.
builder.Services.AddGrpc(options =>
{
options.MaxReceiveMessageSize = ZB.MOM.WW.ScadaBridge.Communication.Grpc.GrpcMessageLimits.MaxMessageBytes;
options.MaxSendMessageSize = ZB.MOM.WW.ScadaBridge.Communication.Grpc.GrpcMessageLimits.MaxMessageBytes;
options.Interceptors.Add<CentralControlAuthInterceptor>();
});
builder.Services.AddSingleton<
@@ -824,6 +826,8 @@ try
// why every environment must carry one before running this build.
builder.Services.AddGrpc(options =>
{
options.MaxReceiveMessageSize = ZB.MOM.WW.ScadaBridge.Communication.Grpc.GrpcMessageLimits.MaxMessageBytes;
options.MaxSendMessageSize = ZB.MOM.WW.ScadaBridge.Communication.Grpc.GrpcMessageLimits.MaxMessageBytes;
options.Interceptors.Add<LocalDbSyncAuthInterceptor>();
options.Interceptors.Add<ControlPlaneAuthInterceptor>();
});
@@ -151,6 +151,14 @@ public class InboundScriptExecutor
/// <param name="methodName">The method name to remove.</param>
public void RemoveHandler(string methodName) => InvalidateMethod(methodName);
/// <summary>Checks compilation without publishing or invalidating a live handler.</summary>
public bool ValidateCompilation(ApiMethod method, out IReadOnlyList<string> errors)
{
var (handler, diagnostics) = Compile(method);
errors = diagnostics;
return handler is not null;
}
/// <summary>
/// Compiles and registers a single API method script. Returns <c>false</c> if the
/// script is empty, fails Roslyn compilation, or violates the script trust model.
@@ -156,7 +156,9 @@ public class ManagementActor : ReceiveActor
private async Task<object> ProcessCommand(ManagementEnvelope envelope, AuthenticatedUser user)
{
using var scope = _serviceProvider.CreateScope();
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user, envelope.ClientIp);
var result = IsConfigurationCommand(envelope.Command)
? await ExecuteConfigurationCommandAsync(scope.ServiceProvider, envelope.Command, user, envelope.ClientIp)
: await DispatchCommand(scope.ServiceProvider, envelope.Command, user, envelope.ClientIp);
return new ManagementSuccess(envelope.CorrelationId, SerializeResult(result));
}
@@ -187,6 +189,41 @@ public class ManagementActor : ReceiveActor
return new ManagementError(correlationId, clientMessage, errorCode);
}
/// <summary>Audited database-only application boundary shared by UI and management envelopes.</summary>
public static async Task<object?> ExecuteConfigurationCommandAsync(IServiceProvider services, object command, AuthenticatedUser user, string? clientIp = null)
{
if (!IsConfigurationCommand(command)) throw new ArgumentException("Not a configuration command.", nameof(command));
var required = GetRequiredRoles(command);
if (required is null || !required.Any(r => user.Roles.Contains(r, StringComparer.OrdinalIgnoreCase)))
throw new UnauthorizedAccessException("The current session is not authorized for this configuration change.");
var transaction = services.GetRequiredService<IConfigurationCommandTransaction>();
return await transaction.ExecuteAsync(sp => DispatchCommand(sp, command, user, clientIp));
}
private static bool IsConfigurationCommand(object command) => command is
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
or CreateAreaCommand or UpdateAreaCommand or MoveAreaCommand or DeleteAreaCommand or SetInstanceAreaCommand
or CreateDataConnectionCommand or UpdateDataConnectionCommand or DeleteDataConnectionCommand
or CreateExternalSystemCommand or UpdateExternalSystemCommand or DeleteExternalSystemCommand
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand
or CreateRoleMappingCommand or UpdateRoleMappingCommand or DeleteRoleMappingCommand or AddScopeRuleCommand or DeleteScopeRuleCommand
or CreateNotificationListCommand or UpdateNotificationListCommand or DeleteNotificationListCommand
or UpdateSmtpConfigCommand or UpdateSmsConfigCommand
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
or SetInstanceNativeAlarmSourceOverrideCommand or DeleteInstanceNativeAlarmSourceOverrideCommand
or SetInstanceNativeAlarmSourceOverridesCommand or SetInstanceOverridesCommand
or RenameNotificationListCommand or AddNotificationRecipientCommand or DeleteNotificationRecipientCommand
or DeleteInstanceAttributeOverrideCommand;
private static Task AfterConfigurationCommit(IServiceProvider services, Func<Task> action)
{
var pending = services.GetService<IConfigurationCommitActions>();
if (pending is null) return action();
pending.Add(action);
return Task.CompletedTask;
}
// Static readonly role sets for the authorization gate. Single-role arms
// stay one-element arrays so every arm has the uniform any-of shape.
private static readonly string[] AdminOnly = [Roles.Administrator];
@@ -226,6 +263,8 @@ public class ManagementActor : ReceiveActor
/// <returns>The set of roles that satisfy the authorization gate, or <c>null</c> if any authenticated user may issue the command.</returns>
internal static IReadOnlyList<string>? GetRequiredRoles(object command) => command switch
{
RenameNotificationListCommand or AddNotificationRecipientCommand or DeleteNotificationRecipientCommand => DesignerOnly,
DeleteInstanceAttributeOverrideCommand => DeployerOnly,
// Administrator operations
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
or ListRoleMappingsCommand or CreateRoleMappingCommand
@@ -335,11 +374,15 @@ public class ManagementActor : ReceiveActor
_ => null
};
private async Task<object?> DispatchCommand(
private static async Task<object?> DispatchCommand(
IServiceProvider sp, object command, AuthenticatedUser user, string? clientIp = null)
{
return command switch
{
RenameNotificationListCommand cmd => await HandleRenameNotificationList(sp, cmd, user.Username),
AddNotificationRecipientCommand cmd => await HandleAddNotificationRecipient(sp, cmd, user.Username),
DeleteNotificationRecipientCommand cmd => await HandleDeleteNotificationRecipient(sp, cmd, user.Username),
DeleteInstanceAttributeOverrideCommand cmd => await HandleDeleteAttributeOverride(sp, cmd, user),
// Templates
ListTemplatesCommand cmd => await HandleListTemplates(sp, cmd),
GetTemplateCommand cmd => await HandleGetTemplate(sp, cmd),
@@ -403,10 +446,10 @@ public class ManagementActor : ReceiveActor
UpdateSiteCommand cmd => await HandleUpdateSite(sp, cmd, user.Username),
DeleteSiteCommand cmd => await HandleDeleteSite(sp, cmd, user.Username),
ListAreasCommand cmd => await HandleListAreas(sp, cmd, user),
CreateAreaCommand cmd => await HandleCreateArea(sp, cmd, user.Username),
DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd, user.Username),
UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user.Username),
MoveAreaCommand cmd => await HandleMoveArea(sp, cmd, user.Username),
CreateAreaCommand cmd => await HandleCreateArea(sp, cmd, user),
DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd, user),
UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user),
MoveAreaCommand cmd => await HandleMoveArea(sp, cmd, user),
// Data Connections
ListDataConnectionsCommand cmd => await HandleListDataConnections(sp, cmd, user),
@@ -1845,7 +1888,7 @@ public class ManagementActor : ReceiveActor
await repo.AddSiteAsync(site);
await repo.SaveChangesAsync();
var commService = sp.GetService<CommunicationService>();
commService?.RefreshSiteAddresses();
await AfterConfigurationCommit(sp, () => { commService?.RefreshSiteAddresses(); return Task.CompletedTask; });
await AuditAsync(sp, user, "Create", "Site", site.Id.ToString(), site.Name, site);
return site;
}
@@ -1864,7 +1907,7 @@ public class ManagementActor : ReceiveActor
await repo.UpdateSiteAsync(site);
await repo.SaveChangesAsync();
var commService = sp.GetService<CommunicationService>();
commService?.RefreshSiteAddresses();
await AfterConfigurationCommit(sp, () => { commService?.RefreshSiteAddresses(); return Task.CompletedTask; });
await AuditAsync(sp, user, "Update", "Site", site.Id.ToString(), site.Name, site);
return site;
}
@@ -1880,7 +1923,7 @@ public class ManagementActor : ReceiveActor
await repo.DeleteSiteAsync(cmd.SiteId);
await repo.SaveChangesAsync();
var commService = sp.GetService<CommunicationService>();
commService?.RefreshSiteAddresses();
await AfterConfigurationCommit(sp, () => { commService?.RefreshSiteAddresses(); return Task.CompletedTask; });
// Dispose the deleted site's cached gRPC channels — RemoveSiteAsync is the
// factory's designed site-deletion disposal path and previously had no
@@ -1891,7 +1934,7 @@ public class ManagementActor : ReceiveActor
// the viewer linger (documented acceptance — see Component-Communication.md).
var grpcFactory = sp.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcClientFactory>();
if (grpcFactory is not null && site is not null)
await grpcFactory.RemoveSiteAsync(site.SiteIdentifier);
await AfterConfigurationCommit(sp, () => grpcFactory.RemoveSiteAsync(site.SiteIdentifier));
await AuditAsync(sp, user, "Delete", "Site", cmd.SiteId.ToString(), site?.Name ?? cmd.SiteId.ToString(), null);
return true;
@@ -1904,27 +1947,20 @@ public class ManagementActor : ReceiveActor
return await repo.GetAreaTreeBySiteIdAsync(cmd.SiteId);
}
private static async Task<object?> HandleCreateArea(IServiceProvider sp, CreateAreaCommand cmd, string user)
private static async Task<object?> HandleCreateArea(IServiceProvider sp, CreateAreaCommand cmd, AuthenticatedUser user)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
var area = new Area(cmd.Name)
{
SiteId = cmd.SiteId,
ParentAreaId = cmd.ParentAreaId
};
await repo.AddAreaAsync(area);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "Area", area.Id.ToString(), area.Name, area);
return area;
EnforceSiteScope(user, cmd.SiteId);
var result = await sp.GetRequiredService<AreaService>().CreateAreaAsync(cmd.Name, cmd.SiteId, cmd.ParentAreaId, user.Username);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleDeleteArea(IServiceProvider sp, DeleteAreaCommand cmd, string user)
private static async Task<object?> HandleDeleteArea(IServiceProvider sp, DeleteAreaCommand cmd, AuthenticatedUser user)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
await repo.DeleteAreaAsync(cmd.AreaId);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Delete", "Area", cmd.AreaId.ToString(), cmd.AreaId.ToString(), null);
return true;
var area = await sp.GetRequiredService<ITemplateEngineRepository>().GetAreaByIdAsync(cmd.AreaId)
?? throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found.");
EnforceSiteScope(user, area.SiteId);
var result = await sp.GetRequiredService<AreaService>().DeleteAreaAsync(cmd.AreaId, user.Username);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
// ========================================================================
@@ -2321,6 +2357,58 @@ public class ManagementActor : ReceiveActor
// Notification handlers
// ========================================================================
private static async Task<object?> HandleRenameNotificationList(IServiceProvider sp, RenameNotificationListCommand cmd, string user)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId)
?? throw new ManagementCommandException("Notification list not found.");
if (string.IsNullOrWhiteSpace(cmd.Name)) throw new ManagementCommandException("Name is required.");
list.Name = cmd.Name.Trim();
await repo.UpdateNotificationListAsync(list);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "NotificationList", list.Id.ToString(), list.Name, list);
return list;
}
private static async Task<object?> HandleAddNotificationRecipient(IServiceProvider sp, AddNotificationRecipientCommand cmd, string user)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId)
?? throw new ManagementCommandException("Notification list not found.");
if (list.Type == NotificationType.Sms ? string.IsNullOrWhiteSpace(cmd.PhoneNumber) : string.IsNullOrWhiteSpace(cmd.EmailAddress))
throw new ManagementCommandException("A contact matching the list's channel is required.");
var recipient = list.Type == NotificationType.Sms
? NotificationRecipient.ForSms(cmd.Name, cmd.PhoneNumber!) : NotificationRecipient.ForEmail(cmd.Name, cmd.EmailAddress!);
recipient.NotificationListId = list.Id;
await repo.AddRecipientAsync(recipient);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "NotificationRecipient", recipient.Id.ToString(), recipient.Name, recipient);
return recipient;
}
private static async Task<object?> HandleDeleteNotificationRecipient(IServiceProvider sp, DeleteNotificationRecipientCommand cmd, string user)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var recipient = await repo.GetRecipientByIdAsync(cmd.RecipientId)
?? throw new ManagementCommandException("Recipient not found.");
await repo.DeleteRecipientAsync(cmd.RecipientId);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Delete", "NotificationRecipient", cmd.RecipientId.ToString(), recipient.Name, null);
return true;
}
private static async Task<object?> HandleDeleteAttributeOverride(IServiceProvider sp, DeleteInstanceAttributeOverrideCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
var row = (await repo.GetOverridesByInstanceIdAsync(cmd.InstanceId)).FirstOrDefault(x => x.AttributeName == cmd.AttributeName);
if (row is null) return true;
await repo.DeleteInstanceAttributeOverrideAsync(row.Id);
await repo.SaveChangesAsync();
await AuditAsync(sp, user.Username, "Delete", "InstanceAttributeOverride", row.Id.ToString(), cmd.AttributeName, null);
return true;
}
private static async Task<object?> HandleListNotificationLists(IServiceProvider sp)
{
var repo = sp.GetRequiredService<INotificationRepository>();
@@ -2448,7 +2536,9 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleUpdateSmtpConfig(IServiceProvider sp, UpdateSmtpConfigCommand cmd, string user)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var config = await repo.GetSmtpConfigurationByIdAsync(cmd.SmtpConfigId)
var config = cmd is CreateSmtpConfigCommand
? new Commons.Entities.Notifications.SmtpConfiguration(cmd.Server, cmd.AuthMode, cmd.FromAddress)
: await repo.GetSmtpConfigurationByIdAsync(cmd.SmtpConfigId)
?? throw new ManagementCommandException($"SmtpConfiguration with ID {cmd.SmtpConfigId} not found.");
config.Host = cmd.Server;
config.Port = cmd.Port;
@@ -2466,13 +2556,14 @@ public class ManagementActor : ReceiveActor
// one (null on every pre-EWS row, which means SMTP).
if (cmd.Transport is not null) config.Transport = cmd.Transport;
ValidateSmtpConfigTransportShape(config);
await repo.UpdateSmtpConfigurationAsync(config);
if (cmd is CreateSmtpConfigCommand) await repo.AddSmtpConfigurationAsync(config);
else await repo.UpdateSmtpConfigurationAsync(config);
await repo.SaveChangesAsync();
// Audit the credential-free shape — the *fact of* the change
// (and which non-secret fields hold) is observable; the secret value is
// not persisted to the audit log where OperationalAuditRoles can read it.
var publicShape = SmtpConfigPublicShape(config);
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, publicShape);
await AuditAsync(sp, user, cmd is CreateSmtpConfigCommand ? "Create" : "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, publicShape);
return publicShape;
}
@@ -2576,7 +2667,9 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<INotificationRepository>();
var configs = await repo.GetAllSmsConfigurationsAsync();
var config = configs.FirstOrDefault(c => c.Id == cmd.SmsConfigId)
var config = cmd is CreateSmsConfigCommand
? new Commons.Entities.Notifications.SmsConfiguration(cmd.AccountSid, cmd.FromNumber)
: configs.FirstOrDefault(c => c.Id == cmd.SmsConfigId)
?? throw new ManagementCommandException($"SmsConfiguration with ID {cmd.SmsConfigId} not found.");
// The AccountSid is interpolated un-escaped into the Twilio request URI
// (SmsNotificationDeliveryAdapter). Reject any value that is not a well-formed
@@ -2597,12 +2690,20 @@ public class ManagementActor : ReceiveActor
// subsequent send. (Contrast SmtpConfiguration.Credentials, which MAY be cleared
// when switching to anonymous SMTP, so that path keeps its null-only guard.)
if (!string.IsNullOrWhiteSpace(cmd.AuthToken)) config.AuthToken = cmd.AuthToken;
await repo.UpdateSmsConfigurationAsync(config);
if (cmd.ConnectionTimeoutSeconds is { } timeout) config.ConnectionTimeoutSeconds = timeout;
if (cmd.MaxRetries is { } retries) config.MaxRetries = retries;
if (cmd.RetryDelay is { } retryDelay) config.RetryDelay = retryDelay;
if (string.IsNullOrWhiteSpace(config.FromNumber) && string.IsNullOrWhiteSpace(config.MessagingServiceSid))
throw new ManagementCommandException("A FromNumber or MessagingServiceSid is required.");
if (cmd is CreateSmsConfigCommand && string.IsNullOrWhiteSpace(config.AuthToken))
throw new ManagementCommandException("AuthToken is required.");
if (cmd is CreateSmsConfigCommand) await repo.AddSmsConfigurationAsync(config);
else await repo.UpdateSmsConfigurationAsync(config);
await repo.SaveChangesAsync();
// Audit the credential-free shape — the AuthToken secret is
// not persisted to the audit log where OperationalAuditRoles can read it.
var publicShape = SmsConfigPublicShape(config);
await AuditAsync(sp, user, "Update", "SmsConfiguration", config.Id.ToString(), config.AccountSid, publicShape);
await AuditAsync(sp, user, cmd is CreateSmsConfigCommand ? "Create" : "Update", "SmsConfiguration", config.Id.ToString(), config.AccountSid, publicShape);
return publicShape;
}
@@ -3414,6 +3515,8 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
var def = new DatabaseConnectionDefinition(cmd.Name, cmd.ConnectionString);
if (cmd.MaxRetries is { } retries) def.MaxRetries = retries;
if (cmd.RetryDelay is { } retryDelay) def.RetryDelay = retryDelay;
await repo.AddDatabaseConnectionAsync(def);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
@@ -3430,6 +3533,8 @@ public class ManagementActor : ReceiveActor
// secret intact — the connection string is elided from List/Get, so a
// round-trip must not be required to re-send it.
if (cmd.ConnectionString is not null) def.ConnectionString = cmd.ConnectionString;
if (cmd.MaxRetries is { } retries) def.MaxRetries = retries;
if (cmd.RetryDelay is { } retryDelay) def.RetryDelay = retryDelay;
await repo.UpdateDatabaseConnectionAsync(def);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
@@ -3511,7 +3616,7 @@ public class ManagementActor : ReceiveActor
await repo.DeleteApiMethodAsync(cmd.ApiMethodId);
await repo.SaveChangesAsync();
if (method != null)
sp.GetService<InboundAPI.InboundScriptExecutor>()?.InvalidateMethod(method.Name);
await AfterConfigurationCommit(sp, () => { sp.GetService<InboundAPI.InboundScriptExecutor>()?.InvalidateMethod(method.Name); return Task.CompletedTask; });
await AuditAsync(sp, user, "Delete", "ApiMethod", cmd.ApiMethodId.ToString(), method?.Name ?? cmd.ApiMethodId.ToString(), null);
return true;
}
@@ -3532,7 +3637,13 @@ public class ManagementActor : ReceiveActor
var executor = sp.GetService<InboundAPI.InboundScriptExecutor>();
if (executor is null)
return null;
if (executor.CompileAndRegister(method, out var errors))
var compiled = executor.ValidateCompilation(method, out var errors);
var actions = sp.GetService<IConfigurationCommitActions>();
if (actions is not null)
actions.Add(() => { executor.CompileAndRegister(method); return Task.CompletedTask; });
else
executor.CompileAndRegister(method);
if (compiled)
return null;
var detail = string.Join("; ", errors);
@@ -3620,16 +3731,13 @@ public class ManagementActor : ReceiveActor
// Area update handler
// ========================================================================
private static async Task<object?> HandleUpdateArea(IServiceProvider sp, UpdateAreaCommand cmd, string user)
private static async Task<object?> HandleUpdateArea(IServiceProvider sp, UpdateAreaCommand cmd, AuthenticatedUser user)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
var area = await repo.GetAreaByIdAsync(cmd.AreaId)
var area = await sp.GetRequiredService<ITemplateEngineRepository>().GetAreaByIdAsync(cmd.AreaId)
?? throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found.");
area.Name = cmd.Name;
await repo.UpdateAreaAsync(area);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "Area", area.Id.ToString(), area.Name, area);
return area;
EnforceSiteScope(user, area.SiteId);
var result = await sp.GetRequiredService<AreaService>().UpdateAreaAsync(cmd.AreaId, cmd.Name, user.Username);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
/// <summary>
@@ -3641,10 +3749,12 @@ public class ManagementActor : ReceiveActor
/// response, matching the sibling folder/template move handlers. The service
/// writes the "Move" audit row itself, so this handler does not audit again.
/// </summary>
private static async Task<object?> HandleMoveArea(IServiceProvider sp, MoveAreaCommand cmd, string user)
private static async Task<object?> HandleMoveArea(IServiceProvider sp, MoveAreaCommand cmd, AuthenticatedUser user)
{
var svc = sp.GetRequiredService<AreaService>();
var result = await svc.MoveAreaAsync(cmd.AreaId, cmd.NewParentAreaId, user);
var area = await sp.GetRequiredService<ITemplateEngineRepository>().GetAreaByIdAsync(cmd.AreaId)
?? throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found.");
EnforceSiteScope(user, area.SiteId);
var result = await sp.GetRequiredService<AreaService>().MoveAreaAsync(cmd.AreaId, cmd.NewParentAreaId, user.Username);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
@@ -1000,7 +1000,8 @@ public class NotificationOutboxActor : ReceiveActor, IWithTimers
var repository = scope.ServiceProvider.GetRequiredService<INotificationOutboxRepository>();
var notification = await repository.GetByIdAsync(query.NotificationId);
if (notification is null)
if (notification is null || (query.VerifiedSourceSiteId is { } site
&& !string.Equals(notification.SourceSiteId, site, StringComparison.Ordinal)))
{
return new NotificationStatusResponse(
query.CorrelationId, Found: false, Status: string.Empty,
@@ -41,6 +41,9 @@ public static class ScriptTrustPolicy
"System.Reflection",
"System.Net",
"System.Runtime.InteropServices",
"System.Runtime.CompilerServices.Unsafe",
"System.Runtime.CompilerServices.RuntimeHelpers",
"System.Runtime.Loader",
"Microsoft.Win32",
// Whole type — no legitimate script use. Exposes process control
// (Exit/FailFast), the host environment, and secrets via environment
@@ -40,7 +40,7 @@ public enum SessionValidationAction
/// <summary>The session is idle-timed-out; reject + sign out.</summary>
Reject,
/// <summary>The role mapping was refreshed; replace the principal and renew the cookie.</summary>
/// <summary>The role mapping was refreshed; replace the principal. The HTTP adapter decides whether renewal is appropriate.</summary>
Replace,
}
@@ -56,9 +56,8 @@ public enum SessionValidationAction
/// computed from the <see cref="JwtTokenService.LastActivityClaimType"/> anchor. This is
/// the explicit, deterministic counterpart to the cookie middleware's
/// <c>ExpireTimeSpan</c> + <c>SlidingExpiration</c> window — both use the SAME idle
/// timeout value, so the explicit check never contradicts the cookie window. A
/// not-timed-out session has its last-activity anchor advanced to "now" (genuine
/// request = activity), mirroring the sliding renew.
/// timeout value. Passive validation preserves the activity anchor. Explicit
/// validated browser activity is also visible to live circuits through the session tracker.
/// </para>
/// <para>
/// <b>Role refresh</b> (default <see cref="SecurityOptions.RoleRefreshThresholdMinutes"/>
@@ -69,10 +68,9 @@ public enum SessionValidationAction
/// </para>
/// <para>
/// <b>Failure policy</b>: a refresh error (e.g. the mapper throws because the DB is
/// unreachable) NEVER signs the user out and NEVER throws out of validation — it returns
/// <see cref="SessionValidationResult.Keep"/>, mirroring the documented "LDAP failure:
/// active sessions continue with current roles" stance. Only the explicit idle-timeout
/// path rejects.
/// unreachable) preserves current grants during ordinary cookie validation.
/// Authoritative circuit mutation checks request fail-closed validation and propagate
/// lookup errors, preventing a mutation from using grants that could not be refreshed.
/// </para>
/// </remarks>
public sealed class CookieSessionValidator
@@ -81,6 +79,7 @@ public sealed class CookieSessionValidator
private readonly SecurityOptions _options;
private readonly TimeProvider _timeProvider;
private readonly ILogger<CookieSessionValidator> _logger;
private readonly SessionActivityTracker? _activity;
/// <summary>Initializes the validator.</summary>
/// <param name="roleMapper">The DB-backed group→role mapping seam (no LDAP) used for the mid-session refresh.</param>
@@ -91,23 +90,24 @@ public sealed class CookieSessionValidator
IGroupRoleMapper<string> roleMapper,
IOptions<SecurityOptions> options,
TimeProvider timeProvider,
ILogger<CookieSessionValidator> logger)
ILogger<CookieSessionValidator> logger, SessionActivityTracker? activity = null)
{
_roleMapper = roleMapper ?? throw new ArgumentNullException(nameof(roleMapper));
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_activity = activity;
}
/// <summary>
/// Evaluates a cookie principal: enforces the idle timeout, then refreshes the
/// role/scope claims from the stored LDAP groups when the role-refresh interval has
/// elapsed. Never throws.
/// elapsed or a caller requires a fresh mapping. Lookup errors propagate when failClosed is set.
/// </summary>
/// <param name="principal">The current cookie principal under validation.</param>
/// <param name="ct">Cancellation token (the request-aborted token in the pipeline).</param>
/// <returns>The action the caller must take and any replacement principal.</returns>
public async Task<SessionValidationResult> ValidateAsync(ClaimsPrincipal? principal, CancellationToken ct = default)
public async Task<SessionValidationResult> ValidateAsync(ClaimsPrincipal? principal, CancellationToken ct = default, bool recordActivity = true, bool forceRefresh = false, bool failClosed = false)
{
// An unauthenticated / null principal is left to the rest of the pipeline.
if (principal?.Identity is not { IsAuthenticated: true })
@@ -129,16 +129,16 @@ public sealed class CookieSessionValidator
return SessionValidationResult.Reject;
}
// 2) Role-mapping refresh — best-effort. Any failure keeps the existing session.
// 2) Ordinary refresh keeps existing grants on failure; authoritative checks propagate it.
try
{
var refreshed = await TryRefreshAsync(principal, now, ct).ConfigureAwait(false);
var refreshed = await TryRefreshAsync(principal, now, ct, recordActivity, forceRefresh).ConfigureAwait(false);
if (refreshed is not null)
{
return SessionValidationResult.Replace(refreshed);
}
}
catch (Exception ex)
catch (Exception ex) when (!failClosed)
{
// SECURITY: never broaden access and never sign the user out on a transient
// refresh fault — keep the existing principal (current roles) and swallow.
@@ -168,16 +168,17 @@ public sealed class CookieSessionValidator
return true;
}
lastActivity = _activity?.LastActivity(principal) ?? lastActivity;
return (now - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
}
// Returns a rebuilt principal when the role-refresh interval has elapsed; null when
// nothing changed. The principal is rebuilt via SessionClaimBuilder so its shape is
// identical to /auth/login.
private async Task<ClaimsPrincipal?> TryRefreshAsync(ClaimsPrincipal principal, DateTimeOffset now, CancellationToken ct)
private async Task<ClaimsPrincipal?> TryRefreshAsync(ClaimsPrincipal principal, DateTimeOffset now, CancellationToken ct, bool recordActivity, bool forceRefresh)
{
var roleRefreshDue = IsRoleRefreshDue(principal, now);
if (!roleRefreshDue)
if (!roleRefreshDue && !forceRefresh)
{
// No mapping refresh due. We deliberately do NOT mint a new principal just to
// advance LastActivity: the cookie middleware's SlidingExpiration already
@@ -207,7 +208,20 @@ public sealed class CookieSessionValidator
// Rebuild identically to /auth/login, advancing BOTH anchors: the role-refresh
// anchor (we just refreshed) and the idle anchor (this is a genuine request).
return SessionClaimBuilder.Build(username, displayName, groups, scope, now);
var rebuilt = SessionClaimBuilder.Build(username, displayName, groups, scope, now);
var identity = (ClaimsIdentity)rebuilt.Identity!;
var sessionId = principal.FindFirst(SessionActivityTracker.SessionIdClaim);
identity.RemoveClaim(identity.FindFirst(SessionActivityTracker.SessionIdClaim)!);
if (sessionId is not null) identity.AddClaim(new Claim(sessionId.Type, sessionId.Value));
if (!recordActivity)
{
identity.RemoveClaim(identity.FindFirst(JwtTokenService.LastActivityClaimType)!);
var lastActivity = _activity?.LastActivity(principal)?.ToString("o")
?? principal.FindFirst(JwtTokenService.LastActivityClaimType)!.Value;
identity.AddClaim(new Claim(JwtTokenService.LastActivityClaimType, lastActivity));
}
else _activity?.Record(rebuilt, now);
return rebuilt;
}
/// <summary>
@@ -68,6 +68,7 @@ public static class ServiceCollectionExtensions
// is the wall clock. TryAddSingleton keeps the Host free to register its own.
services.TryAddSingleton(TimeProvider.System);
services.AddScoped<CookieSessionValidator>();
services.AddSingleton<SessionActivityTracker>();
// Audit Actor wiring (Phase 3): the user-facing inbound API audit path
// sources AuditEvent.Actor from the authenticated principal via this
@@ -157,6 +158,11 @@ public static class ServiceCollectionExtensions
// throws — CookieSessionValidator.ValidateAsync swallows refresh faults and
// keeps the session (mirrors "LDAP failure: active sessions continue").
options.Events.OnValidatePrincipal = OnValidatePrincipalAsync;
options.Events.OnCheckSlidingExpiration = context =>
{
if (IsPassiveSessionRequest(context.HttpContext)) context.ShouldRenew = false;
return Task.CompletedTask;
};
});
}
@@ -218,13 +224,19 @@ public static class ServiceCollectionExtensions
return services;
}
/// <summary>Requests that cannot by themselves establish user activity.</summary>
internal static bool IsPassiveSessionRequest(HttpContext context)
=> context.Request.Path.StartsWithSegments("/auth/ping")
|| context.Request.Path.StartsWithSegments("/auth/activity")
|| context.Request.Path.StartsWithSegments("/_blazor");
/// <summary>
/// The thin <see cref="CookieAuthenticationEvents.OnValidatePrincipal"/>
/// adapter. It resolves the request-scoped <see cref="CookieSessionValidator"/>,
/// asks it for a decision, and applies it to the cookie context:
/// <list type="bullet">
/// <item><see cref="SessionValidationAction.Reject"/> → <see cref="CookieValidatePrincipalContext.RejectPrincipal"/> + sign out (idle-timeout — the only sign-out path).</item>
/// <item><see cref="SessionValidationAction.Replace"/> → <see cref="CookieValidatePrincipalContext.ReplacePrincipal"/> + <c>ShouldRenew = true</c> (role mapping refreshed).</item>
/// <item><see cref="SessionValidationAction.Replace"/> → <see cref="CookieValidatePrincipalContext.ReplacePrincipal"/> + renewal only for non-passive requests (role mapping refreshed).</item>
/// <item><see cref="SessionValidationAction.Keep"/> → no-op (no refresh due, or a swallowed refresh fault).</item>
/// </list>
/// All logic lives in <see cref="CookieSessionValidator.ValidateAsync"/>, which never
@@ -237,7 +249,7 @@ public static class ServiceCollectionExtensions
var validator = context.HttpContext.RequestServices.GetRequiredService<CookieSessionValidator>();
var result = await validator
.ValidateAsync(context.Principal, context.HttpContext.RequestAborted)
.ValidateAsync(context.Principal, context.HttpContext.RequestAborted, recordActivity: !IsPassiveSessionRequest(context.HttpContext))
.ConfigureAwait(false);
await ApplyValidationResultAsync(context, result).ConfigureAwait(false);
@@ -272,7 +284,7 @@ public static class ServiceCollectionExtensions
// Role mapping refreshed from stored groups — swap in the rebuilt
// principal and re-issue the cookie so the new claims persist.
context.ReplacePrincipal(result.Principal);
context.ShouldRenew = true;
context.ShouldRenew = !IsPassiveSessionRequest(context.HttpContext);
break;
case SessionValidationAction.Keep:
@@ -0,0 +1,41 @@
using System.Security.Claims;
namespace ZB.MOM.WW.ScadaBridge.Security;
/// <summary>
/// Bounded, process-local activity shared by an HTTP cookie and its existing Blazor
/// circuit. Only authenticated, validated interaction requests advance this clock.
/// The protected cookie remains the durable anchor on restart or node change.
/// </summary>
public sealed class SessionActivityTracker
{
/// <summary>Opaque per-login identifier carried in the protected cookie.</summary>
public const string SessionIdClaim = "zb:sessionid";
private const int Capacity = 10_000;
private readonly object _sync = new();
private readonly Dictionary<string, DateTimeOffset> _activity = new(StringComparer.Ordinal);
/// <summary>Returns the newer of the protected cookie anchor and this node's observed activity.</summary>
public DateTimeOffset? LastActivity(ClaimsPrincipal principal)
{
if (!DateTimeOffset.TryParse(principal.FindFirst(JwtTokenService.LastActivityClaimType)?.Value, out var stamp))
return null;
var id = principal.FindFirst(SessionIdClaim)?.Value;
lock (_sync)
return id is not null && _activity.TryGetValue(id, out var seen) && seen > stamp ? seen : stamp;
}
/// <summary>Records a validated interaction without changing another login's activity.</summary>
public void Record(ClaimsPrincipal principal, DateTimeOffset now)
{
var id = principal.FindFirst(SessionIdClaim)?.Value;
if (id is null || principal.Identity?.IsAuthenticated != true) return;
lock (_sync)
{
if (!_activity.ContainsKey(id) && _activity.Count >= Capacity)
_activity.Remove(_activity.MinBy(p => p.Value).Key);
if (!_activity.TryGetValue(id, out var previous) || now > previous)
_activity[id] = now;
}
}
}
@@ -11,10 +11,11 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
/// refresh to "rebuild claims identically to /auth/login".
/// </summary>
/// <remarks>
/// The claim shape is exactly what the login endpoint historically minted, plus the
/// two additions:
/// The login and refresh paths share the following identity and session claims:
/// <list type="bullet">
/// <item><see cref="ClaimTypes.NameIdentifier"/> — stable antiforgery identity.</item>
/// <item><see cref="ClaimTypes.Name"/> — resolves <c>Identity.Name</c>.</item>
/// <item><see cref="SessionActivityTracker.SessionIdClaim"/> — isolates activity to this login.</item>
/// <item><see cref="JwtTokenService.DisplayNameClaimType"/> — human display name.</item>
/// <item><see cref="JwtTokenService.UsernameClaimType"/> — canonical username.</item>
/// <item><see cref="JwtTokenService.RoleClaimType"/> — one per mapped role.</item>
@@ -64,13 +65,15 @@ public static class SessionClaimBuilder
var claims = new List<Claim>
{
// Antiforgery binds tokens to this stable identity, not mutable role/activity claims.
new(ClaimTypes.NameIdentifier, username),
new(ClaimTypes.Name, username),
new(SessionActivityTracker.SessionIdClaim, Guid.NewGuid().ToString("N")),
new(JwtTokenService.DisplayNameClaimType, displayName),
new(JwtTokenService.UsernameClaimType, username),
// Role-refresh anchor AND idle anchor are seeded from the same instant at
// build time. They then diverge: OnValidatePrincipal advances LastActivity
// on every request but only advances LastRoleRefresh when it actually
// re-runs the mapping.
// build time. Passive requests preserve LastActivity; accepted activity
// advances it. LastRoleRefresh advances when the mapping is re-run.
new(JwtTokenService.LastRoleRefreshClaimType, refreshStamp),
new(JwtTokenService.LastActivityClaimType, refreshStamp),
};
@@ -428,6 +428,10 @@ public class InstanceActor : ReceiveActor
/// <inheritdoc />
protected override void PostStop()
{
foreach (var connectionName in _tagsByConnection.Keys)
_dclManager?.Tell(new UnsubscribeTagsRequest(
Guid.NewGuid().ToString("N"), _instanceUniqueName, connectionName, DateTimeOffset.UtcNow), Self);
// Cancel any armed tag-subscribe watchdogs so they cannot fire after stop.
foreach (var state in _tagSubscribeStates.Values)
state.Timer?.Cancel();
@@ -52,6 +52,8 @@ public class NativeAlarmActor : ReceiveActor
/// <summary>Buffer accumulating a snapshot replay until <see cref="AlarmTransitionKind.SnapshotComplete"/>.</summary>
private readonly Dictionary<string, NativeAlarmTransition> _snapshotBuffer = new();
private Guid? _snapshotId;
private Guid? _lastCompletedSnapshotId;
/// <summary>
/// Pending mirror upserts coalesced by source reference (P4): under an alarm storm each
@@ -135,6 +137,9 @@ public class NativeAlarmActor : ReceiveActor
/// <inheritdoc />
protected override void PostStop()
{
_dclManager.Tell(new UnsubscribeAlarmsRequest(
Guid.NewGuid().ToString("N"), _instanceName, _source.ConnectionName,
_source.SourceReference, DateTimeOffset.UtcNow), Self);
_retryTimer?.Cancel();
_flushTimer?.Cancel();
// Best-effort final flush of any coalesced upserts so a graceful stop does not lose the
@@ -227,14 +232,29 @@ public class NativeAlarmActor : ReceiveActor
private void HandleTransition(NativeAlarmTransitionUpdate update)
{
var t = update.Transition;
if (t.SnapshotSourceReference is { } scope && scope != _source.SourceReference)
return;
switch (t.Kind)
{
case AlarmTransitionKind.SnapshotStart:
if (t.SnapshotId is null || t.SnapshotId == _lastCompletedSnapshotId || t.SnapshotId == _snapshotId)
return;
_snapshotId = t.SnapshotId;
_snapshotBuffer.Clear();
break;
case AlarmTransitionKind.Snapshot:
if (t.SnapshotId != _snapshotId)
return;
_snapshotBuffer[t.SourceReference] = t;
break;
case AlarmTransitionKind.SnapshotComplete:
if (t.SnapshotId != _snapshotId || (t.SnapshotId is not null && t.SnapshotId == _lastCompletedSnapshotId))
return;
ApplySnapshotSwap();
_lastCompletedSnapshotId = t.SnapshotId;
_snapshotId = null;
break;
default:
@@ -310,6 +330,10 @@ public class NativeAlarmActor : ReceiveActor
private void HandleSourceUnavailable(NativeAlarmSourceUnavailable msg)
{
// An interrupted refresh is not a snapshot. Never merge its partial rows
// into the next connection's replay.
_snapshotBuffer.Clear();
_snapshotId = null;
// Keep last-known conditions (uncertain) rather than clearing them; the
// reconnect snapshot reconciles state. No emit — avoids a flap.
_logger.LogWarning(
@@ -58,6 +58,7 @@ internal sealed class AuditingDbCommand : DbCommand
private readonly ILogger _logger;
private DbConnection? _wrappingConnection;
private DbTransaction? _wrappingTransaction;
/// <summary>
/// Initializes a new auditing database command decorator.
@@ -171,8 +172,13 @@ internal sealed class AuditingDbCommand : DbCommand
/// <inheritdoc />
protected override DbTransaction? DbTransaction
{
get => _inner.Transaction;
set => _inner.Transaction = value;
get => _inner.Transaction is null ? null : _wrappingTransaction;
set
{
// Preserve the public wrapper, while giving the provider its own transaction.
_inner.Transaction = value is AuditingDbTransaction auditing ? auditing.Inner : value;
_wrappingTransaction = value;
}
}
/// <inheritdoc />
@@ -17,8 +17,9 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// </summary>
/// <remarks>
/// <para>
/// All other <see cref="DbConnection"/> members forward to the inner connection
/// unchanged so the script keeps full ADO.NET semantics (transactions, state
/// Transactions and command connection readback retain this wrapper so they cannot
/// expose an unaudited provider connection. Other members forward to the inner connection
/// so the script keeps full ADO.NET semantics (transactions, state
/// transitions, server-version queries, etc.). Disposing the wrapper disposes
/// the inner connection — the caller is still responsible for disposal per
/// the <see cref="IDatabaseGateway"/> contract.
@@ -147,7 +148,13 @@ internal sealed class AuditingDbConnection : DbConnection
/// <inheritdoc />
protected override DbTransaction BeginDbTransaction(IsolationLevel isolationLevel)
=> _inner.BeginTransaction(isolationLevel);
=> new AuditingDbTransaction(_inner.BeginTransaction(isolationLevel), this);
/// <inheritdoc />
protected override async ValueTask<DbTransaction> BeginDbTransactionAsync(
IsolationLevel isolationLevel, CancellationToken cancellationToken)
=> new AuditingDbTransaction(
await _inner.BeginTransactionAsync(isolationLevel, cancellationToken).ConfigureAwait(false), this);
/// <inheritdoc />
protected override DbCommand CreateDbCommand()
@@ -168,7 +175,7 @@ internal sealed class AuditingDbConnection : DbConnection
// threaded alongside _executionId. Null for non-routed runs.
_parentExecutionId,
// Flow Debugger: the DbWrite rows ride the inline collector.
_onAudit);
_onAudit) { Connection = this };
}
/// <inheritdoc />
@@ -0,0 +1,70 @@
using System.Data;
using System.Data.Common;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <summary>
/// Keeps transaction-derived commands inside the auditing connection boundary.
/// Only the command decorator can unwrap the provider transaction for assignment.
/// </summary>
internal sealed class AuditingDbTransaction(DbTransaction inner, AuditingDbConnection connection) : DbTransaction
{
/// <summary>The provider transaction, for internal command assignment only.</summary>
internal DbTransaction Inner => inner;
/// <inheritdoc />
protected override DbConnection? DbConnection => inner.Connection is null ? null : connection;
/// <inheritdoc />
public override IsolationLevel IsolationLevel => inner.IsolationLevel;
/// <inheritdoc />
public override bool SupportsSavepoints => inner.SupportsSavepoints;
/// <inheritdoc />
public override void Commit() => inner.Commit();
/// <inheritdoc />
public override Task CommitAsync(CancellationToken cancellationToken = default) => inner.CommitAsync(cancellationToken);
/// <inheritdoc />
public override void Rollback() => inner.Rollback();
/// <inheritdoc />
public override Task RollbackAsync(CancellationToken cancellationToken = default) => inner.RollbackAsync(cancellationToken);
/// <inheritdoc />
public override void Save(string savepointName) => inner.Save(savepointName);
/// <inheritdoc />
public override Task SaveAsync(string savepointName, CancellationToken cancellationToken = default)
=> inner.SaveAsync(savepointName, cancellationToken);
/// <inheritdoc />
public override void Rollback(string savepointName) => inner.Rollback(savepointName);
/// <inheritdoc />
public override Task RollbackAsync(string savepointName, CancellationToken cancellationToken = default)
=> inner.RollbackAsync(savepointName, cancellationToken);
/// <inheritdoc />
public override void Release(string savepointName) => inner.Release(savepointName);
/// <inheritdoc />
public override Task ReleaseAsync(string savepointName, CancellationToken cancellationToken = default)
=> inner.ReleaseAsync(savepointName, cancellationToken);
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing) inner.Dispose();
base.Dispose(disposing);
}
/// <inheritdoc />
public override ValueTask DisposeAsync()
{
GC.SuppressFinalize(this);
return inner.DisposeAsync();
}
}
@@ -11,7 +11,9 @@ namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
/// <para>
/// Thread-model note: the indexer get/set block synchronously
/// on the Instance Actor Ask (and, for data-connected attributes, the DCL
/// round-trip). This is safe because script bodies execute on the dedicated
/// round-trip). Runtime helper continuations use ConfigureAwait(false), so they
/// can complete even when every dedicated worker is blocked in an indexer.
/// Script bodies execute on the dedicated
/// <see cref="ScriptExecutionScheduler"/> threads, not the
/// shared <see cref="System.Threading.ThreadPool"/> — so a blocked accessor
/// cannot starve unrelated Akka dispatchers or HTTP request handling. The async
@@ -701,7 +701,7 @@ public class ScriptRuntimeContext
var request = new GetAttributeRequest(
correlationId, _instanceName, attributeName, DateTimeOffset.UtcNow);
var response = await _instanceActor.Ask<GetAttributeResponse>(request, _askTimeout);
var response = await _instanceActor.Ask<GetAttributeResponse>(request, _askTimeout).ConfigureAwait(false);
if (!response.Found)
{
@@ -762,7 +762,7 @@ public class ScriptRuntimeContext
public async Task<bool> WaitAttribute(
string name, string? targetValueEncoded, Func<object?, bool>? predicate, TimeSpan timeout,
bool requireGoodQuality = false)
=> (await WaitInternal(name, targetValueEncoded, predicate, timeout, requireGoodQuality)).Matched;
=> (await WaitInternal(name, targetValueEncoded, predicate, timeout, requireGoodQuality).ConfigureAwait(false)).Matched;
/// <summary>
/// WaitForAttribute (spec §3): the richer overload backing <c>Attributes.WaitForAsync</c>
@@ -780,7 +780,7 @@ public class ScriptRuntimeContext
string name, string? targetValueEncoded, Func<object?, bool>? predicate, TimeSpan timeout,
bool requireGoodQuality = false)
{
var r = await WaitInternal(name, targetValueEncoded, predicate, timeout, requireGoodQuality);
var r = await WaitInternal(name, targetValueEncoded, predicate, timeout, requireGoodQuality).ConfigureAwait(false);
return new WaitResult(r.Matched, r.Value, r.Quality, r.TimedOut);
}
@@ -816,7 +816,7 @@ public class ScriptRuntimeContext
try
{
response = await _instanceActor.Ask<WaitForAttributeResponse>(
req, timeout + _askTimeout, _scriptTimeoutToken);
req, timeout + _askTimeout, _scriptTimeoutToken).ConfigureAwait(false);
}
catch (AskTimeoutException)
{
@@ -911,7 +911,7 @@ public class ScriptRuntimeContext
{
// Ask — mutation serialized through the Instance Actor mailbox; the reply
// carries the device-write outcome for data-connected attributes.
response = await _instanceActor.Ask<SetStaticAttributeResponse>(command, _askTimeout);
response = await _instanceActor.Ask<SetStaticAttributeResponse>(command, _askTimeout).ConfigureAwait(false);
}
catch (Exception ex) when (sw is not null)
{
@@ -979,7 +979,7 @@ public class ScriptRuntimeContext
Guid? stepId = _isTraced ? Guid.NewGuid() : null;
using var step = stepId is { } batchStepId ? TraceStepScope.Enter(batchStepId) : null;
var batchResp = await _instanceActor.Ask<WriteAttributeBatchResponse>(
batchReq, TimeSpan.FromSeconds(35), _scriptTimeoutToken);
batchReq, TimeSpan.FromSeconds(35), _scriptTimeoutToken).ConfigureAwait(false);
if (sw is not null)
{
@@ -1003,7 +1003,7 @@ public class ScriptRuntimeContext
throw new InvalidOperationException(
$"WriteBatchAndWait write failed: {batchResp.ErrorMessage}");
return await WaitAttribute(responseAttr, responseEncoded, null, timeout);
return await WaitAttribute(responseAttr, responseEncoded, null, timeout).ConfigureAwait(false);
}
/// <summary>