Resolve all high-severity architecture review findings
This commit is contained in:
@@ -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><AuthorizeView></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();
|
||||
|
||||
+27
-53
@@ -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 ?? "";
|
||||
|
||||
+6
-4
@@ -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(
|
||||
|
||||
+6
-20
@@ -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; }
|
||||
|
||||
+6
-23
@@ -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();
|
||||
|
||||
+1
-1
@@ -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))
|
||||
{
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
+16
-2
@@ -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)
|
||||
{
|
||||
|
||||
+8
-19
@@ -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; }
|
||||
|
||||
+4
-2
@@ -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();
|
||||
}
|
||||
|
||||
+9
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-40
@@ -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();
|
||||
|
||||
+8
-27
@@ -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();
|
||||
|
||||
+7
-11
@@ -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 });
|
||||
|
||||
+22
@@ -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
-2
@@ -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&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;
|
||||
|
||||
+10
-1
@@ -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>();
|
||||
|
||||
+56
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user