using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
///
/// Central actor that handles all management commands from the CLI (via the HTTP /management endpoint).
/// Receives ManagementEnvelope messages, authorizes based on roles, then delegates to
/// the appropriate service or repository using scoped DI.
///
public class ManagementActor : ReceiveActor
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger _logger;
/// Initializes a new instance of and registers the message handler.
/// DI service provider for scoped repository and service access.
/// Logger instance.
public ManagementActor(IServiceProvider serviceProvider, ILogger logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
Receive(HandleEnvelope);
}
///
/// Builds the supervision strategy for . Per the project's
/// Akka.NET conventions, coordinator-style actors use a Resume-based strategy so a faulted
/// child preserves its state rather than restarting. spawns
/// no children today, but declaring the strategy explicitly matches the convention and
/// makes the contract correct ahead of any future worker actors.
///
/// A one-for-one Resume strategy with no retry limit.
public static SupervisorStrategy CreateSupervisorStrategy() =>
new OneForOneStrategy(
maxNrOfRetries: -1,
withinTimeRange: System.Threading.Timeout.InfiniteTimeSpan,
decider: Decider.From(_ => Directive.Resume));
///
protected override SupervisorStrategy SupervisorStrategy() => CreateSupervisorStrategy();
///
/// Serializer settings for command results.
/// keeps EF-backed entity graphs with bidirectional navigation properties from throwing;
/// camelCase matches what the CLI / HTTP layer expect.
///
private static readonly JsonSerializerOptions ResultSerializerOptions = new()
{
ReferenceHandler = ReferenceHandler.IgnoreCycles,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
///
/// Serializes a command result to JSON using — the same
/// serializer the HTTP endpoint uses — with cycle-safe settings.
///
///
/// Rejects tuple results outright. writes only
/// properties by default, and a ValueTuple exposes its members as public
/// fields — so a handler that returns a repository's
/// (rows, totalCount) tuple serializes to the literal {} , silently
/// handing every caller an empty result with no error anywhere in the stack. That is
/// exactly how audit-config query shipped broken (deferred-work register row
/// 46). Failing loudly here converts the whole class of bug into an immediate,
/// obvious error the first time the handler is exercised; the fix is always to
/// project the tuple into a named result record before returning it.
///
/// The command result object to serialize, or null.
/// The JSON string representation of , or "null" when null.
///
/// Thrown when is a tuple, which cannot round-trip through
/// the JSON contract.
///
public static string SerializeResult(object? result)
{
if (result is System.Runtime.CompilerServices.ITuple)
{
throw new InvalidOperationException(
$"Management command results must be named types, not tuples — "
+ $"System.Text.Json serializes '{result.GetType().Name}' to '{{}}' because tuple "
+ "members are fields, not properties. Project the tuple into a result record.");
}
return JsonSerializer.Serialize(result, ResultSerializerOptions);
}
private void HandleEnvelope(ManagementEnvelope envelope)
{
var sender = Sender;
var correlationId = envelope.CorrelationId;
var user = envelope.User;
// Check authorization. A command may require ANY of a set of roles
// (any-of): the caller is authorized when they hold at least one.
var requiredRoles = GetRequiredRoles(envelope.Command);
if (requiredRoles is not null
&& !requiredRoles.Any(r => user.Roles.Contains(r, StringComparer.OrdinalIgnoreCase)))
{
sender.Tell(new ManagementUnauthorized(correlationId,
$"One of roles [{string.Join(", ", requiredRoles)}] required for {envelope.Command.GetType().Name}"));
return;
}
// Process the command and pipe the mapped result back to the captured sender.
// PipeTo (rather than Task.Run + Tell-from-continuation) is the project's Akka.NET
// convention: faults are mapped in the failure continuation, no actor state is
// captured in the closure, and synchronous faults in command setup are still mapped.
ProcessCommand(envelope, user)
.PipeTo(sender,
success: result => result,
failure: ex => MapFault(ex, correlationId, envelope.Command));
}
///
/// Runs a command on a scoped service provider and maps the result to a management
/// response message. Returns a faulted task on error so the PipeTo failure
/// continuation maps it uniformly.
///
private async Task ProcessCommand(ManagementEnvelope envelope, AuthenticatedUser user)
{
using var scope = _serviceProvider.CreateScope();
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));
}
///
/// Maps an exception from command processing to the appropriate management response.
///
private object MapFault(Exception ex, string correlationId, object command)
{
// PipeTo wraps continuation exceptions; unwrap to find the real cause.
var cause = ex is AggregateException agg ? agg.Flatten().InnerException ?? ex : ex;
if (cause is SiteScopeViolationException scope)
return new ManagementUnauthorized(correlationId, scope.Message);
_logger.LogError(cause, "Management command {Command} failed (CorrelationId={CorrelationId})",
command.GetType().Name, correlationId);
// Curated handler failures (ManagementCommandException) carry a message
// that is safe to surface to the caller. Any other exception is an
// unanticipated fault whose raw .Message can disclose internal detail
// (server/database names, constraint names, stack context) — return a
// generic message and let the operator correlate via the server log
// using the correlation ID.
var clientMessage = cause is ManagementCommandException
? cause.Message
: $"An internal error occurred (CorrelationId={correlationId}).";
var errorCode = cause is ManagementCommandException { Code: { Length: > 0 } code } ? code : "COMMAND_FAILED";
return new ManagementError(correlationId, clientMessage, errorCode);
}
/// Audited database-only application boundary shared by UI and management envelopes.
public static async Task 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();
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 action)
{
var pending = services.GetService();
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];
private static readonly string[] DesignerOnly = [Roles.Designer];
private static readonly string[] DeployerOnly = [Roles.Deployer];
private static readonly string[] OperatorOnly = [Roles.Operator];
private static readonly string[] VerifierOnly = [Roles.Verifier];
// arch-review F3: Flow Debugger Replay is an audit read, so it carries the same role
// set as the RequireFlowDebugger page policy ({Deployer, Administrator, Viewer}). The
// three Live run commands stay DeployerOnly.
private static readonly string[] FlowDebuggerReplayRoles = AuthorizationPolicies.FlowDebuggerAccessRoles;
// arch-review C6: area management is reconciled to any-of [Designer, Deployer].
// Areas are authored on both real surfaces — the Designer template/topology
// tooling AND the Central UI Deployment Topology workflow (Topology.razor is
// RequireDeployment) — so a pure Deployer must be able to create/update/delete
// them. The three requirements docs' historical "Administrator" assignment was
// stale; Administrator is not implicitly included here, matching the actor's
// any-of treatment of every other Designer/Deployer command.
private static readonly string[] AreaManagers = [Roles.Designer, Roles.Deployer];
// arch-review UA1: the Secured Writes history table exposes
// process-sensitive tag values, so reading it is restricted to the
// two-person Secured Write roles plus Administrator (any-of) — NOT any
// authenticated user like the other list/query commands.
private static readonly string[] SecuredWriteReaders =
[Roles.Operator, Roles.Verifier, Roles.Administrator];
///
/// Returns the set of roles that satisfy the authorization gate for
/// (any-of semantics), or null when the
/// command is available to any authenticated user. Internal so the
/// authorization matrix can be asserted directly in tests.
///
/// The management command to authorize.
/// The set of roles that satisfy the authorization gate, or null if any authenticated user may issue the command.
internal static IReadOnlyList? GetRequiredRoles(object command) => command switch
{
RenameNotificationListCommand or AddNotificationRecipientCommand or DeleteNotificationRecipientCommand => DesignerOnly,
DeleteInstanceAttributeOverrideCommand => DeployerOnly,
// Administrator operations
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
or ListRoleMappingsCommand or CreateRoleMappingCommand
or UpdateRoleMappingCommand or DeleteRoleMappingCommand
or ListApiKeysCommand or CreateApiKeyCommand or DeleteApiKeyCommand
or UpdateApiKeyCommand or SetApiKeyMethodsCommand
or ListScopeRulesCommand or AddScopeRuleCommand or DeleteScopeRuleCommand
// QueryAuditLogCommand: legacy Action/EntityType filter on the
// configuration audit log. Gated Admin-only so this older path is
// never looser than the keyset-paged `/api/audit/query` endpoint
// (which requires OperationalAuditRoles). New audit consumers
// should use the REST endpoint; this command is retained for
// backward compatibility with the CentralUI Configuration Audit
// Log page.
or QueryAuditLogCommand
// SMTP/SMS provider-config updates rotate stored secrets
// (SMTP Credentials, Twilio AuthToken). Both the /notifications/smtp and
// /notifications/sms Central UI pages enforce RequireAdmin, so the actor
// gate must match — otherwise a Designer blocked in the UI could still
// rotate a production credential via the CLI/Management API.
or UpdateSmtpConfigCommand
or UpdateSmsConfigCommand
// Server-certificate trust (arch-review C4). These mutate the site's
// trusted-peer PKI store — Admin, matching the Admin-gated UI cert
// page. Site-side reconcile semantics are owned by the site
// CertStoreActor (plan 03); this handler only relays.
or TrustServerCertCommand or RemoveServerCertCommand or ListServerCertsCommand => AdminOnly,
// Area management — any-of [Designer, Deployer] (arch-review C6).
// Exposed both in the Designer tooling and inside the Central UI
// Deployment Topology workflow (RequireDeployment), so both roles qualify.
CreateAreaCommand or UpdateAreaCommand or MoveAreaCommand
or DeleteAreaCommand => AreaManagers,
// Designer operations
CreateTemplateCommand or UpdateTemplateCommand or DeleteTemplateCommand
or ValidateTemplateCommand
or CreateExternalSystemCommand or UpdateExternalSystemCommand
or DeleteExternalSystemCommand
or CreateExternalSystemMethodCommand or UpdateExternalSystemMethodCommand
or DeleteExternalSystemMethodCommand
or CreateNotificationListCommand or UpdateNotificationListCommand
or DeleteNotificationListCommand
or CreateDataConnectionCommand or UpdateDataConnectionCommand
or DeleteDataConnectionCommand or MoveDataConnectionCommand
or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
or AddTemplateAlarmCommand or UpdateTemplateAlarmCommand or DeleteTemplateAlarmCommand
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand
or AddTemplateCompositionCommand or DeleteTemplateCompositionCommand
or ResyncInheritedMembersCommand
or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand
or CreateSharedSchemaCommand or UpdateSharedSchemaCommand or DeleteSharedSchemaCommand
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand
or CreateTemplateFolderCommand or RenameTemplateFolderCommand
or MoveTemplateFolderCommand or ReorderTemplateFolderCommand or DeleteTemplateFolderCommand
or MoveTemplateToFolderCommand
// OPC UA / MxGateway design-time remote queries (arch-review C4):
// browse the address space, run a bounded search, and probe/verify an
// endpoint. Read-only against the live site server; gated Design to
// match the connection editor these back.
or BrowseNodeCommand or SearchAddressSpaceCommand or VerifyEndpointCommand
or ExportBundleCommand => DesignerOnly,
// Transport import operations (mirror the Central UI gating:
// Administrator for inbound bundle handling because they mutate
// cross-cutting configuration; Export stays Designer because it only
// reads).
PreviewBundleCommand or ImportBundleCommand => AdminOnly,
// Deployer operations
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtDeploySiteCommand or MgmtEnableInstanceCommand
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
or SetInstanceNativeAlarmSourceOverrideCommand or DeleteInstanceNativeAlarmSourceOverrideCommand
or SetInstanceNativeAlarmSourceOverridesCommand
or GetDeploymentDiffCommand
or MgmtDeployArtifactsCommand
or QueryDeploymentsCommand
or RetryParkedMessageCommand or DiscardParkedMessageCommand
or DebugSnapshotCommand => DeployerOnly,
// Flow Debugger (design 2026-09-05 §9 / D6, arch-review F3; script entry added by
// design 2026-09-08). All three Live run commands execute scripts and write
// attributes on operator input, so they stay Deployer-only, like the Debug View.
// Replay only reads the audit chain, so it additionally accepts the OperationalAudit
// roles — the Audit Log / Execution Chain drill-ins are the feature's main entry for
// a past incident and an auditor must be able to use them.
FlowDebugRunMethodCommand or FlowDebugRunValueChangeCommand or FlowDebugRunScriptCommand => DeployerOnly,
FlowDebugReplayCommand => FlowDebuggerReplayRoles,
// Two-person secured write. Submit is an Operator action;
// approve/reject are Verifier actions. Separation of duties (a write may
// not be verified by its submitter) is enforced inside the handler — the
// role gate only ensures the caller holds the right coarse role.
SubmitSecuredWriteCommand => OperatorOnly,
RejectSecuredWriteCommand or ApproveSecuredWriteCommand => VerifierOnly,
// ListSecuredWritesCommand is read-only, but the history table exposes
// process-sensitive tag values, so it is NOT open to any authenticated
// user — restricted to the Secured Write roles + Administrator
// (arch-review UA1).
ListSecuredWritesCommand => SecuredWriteReaders,
// Read-only queries -- any authenticated user
_ => null
};
private static async Task 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),
CreateTemplateCommand cmd => await HandleCreateTemplate(sp, cmd, user.Username),
UpdateTemplateCommand cmd => await HandleUpdateTemplate(sp, cmd, user.Username),
DeleteTemplateCommand cmd => await HandleDeleteTemplate(sp, cmd, user.Username),
ValidateTemplateCommand cmd => await HandleValidateTemplate(sp, cmd),
GetResolvedTemplateMembersCommand cmd => await HandleGetResolvedTemplateMembers(sp, cmd),
ResyncInheritedMembersCommand cmd => await HandleResyncInheritedMembers(sp, cmd, user.Username),
// Template members
AddTemplateAttributeCommand cmd => await HandleAddAttribute(sp, cmd, user.Username),
UpdateTemplateAttributeCommand cmd => await HandleUpdateAttribute(sp, cmd, user.Username),
DeleteTemplateAttributeCommand cmd => await HandleDeleteAttribute(sp, cmd, user.Username),
AddTemplateAlarmCommand cmd => await HandleAddAlarm(sp, cmd, user.Username),
UpdateTemplateAlarmCommand cmd => await HandleUpdateAlarm(sp, cmd, user.Username),
DeleteTemplateAlarmCommand cmd => await HandleDeleteAlarm(sp, cmd, user.Username),
AddTemplateNativeAlarmSourceCommand cmd => await HandleAddNativeAlarmSource(sp, cmd, user.Username),
UpdateTemplateNativeAlarmSourceCommand cmd => await HandleUpdateNativeAlarmSource(sp, cmd, user.Username),
DeleteTemplateNativeAlarmSourceCommand cmd => await HandleDeleteNativeAlarmSource(sp, cmd, user.Username),
ListTemplateNativeAlarmSourcesCommand cmd => await HandleListNativeAlarmSources(sp, cmd),
AddTemplateScriptCommand cmd => await HandleAddScript(sp, cmd, user.Username),
UpdateTemplateScriptCommand cmd => await HandleUpdateScript(sp, cmd, user.Username),
DeleteTemplateScriptCommand cmd => await HandleDeleteScript(sp, cmd, user.Username),
AddTemplateCompositionCommand cmd => await HandleAddComposition(sp, cmd, user.Username),
DeleteTemplateCompositionCommand cmd => await HandleDeleteComposition(sp, cmd, user.Username),
// Template folders
ListTemplateFoldersCommand => await HandleListTemplateFolders(sp),
CreateTemplateFolderCommand cmd => await HandleCreateTemplateFolder(sp, cmd, user.Username),
RenameTemplateFolderCommand cmd => await HandleRenameTemplateFolder(sp, cmd, user.Username),
MoveTemplateFolderCommand cmd => await HandleMoveTemplateFolder(sp, cmd, user.Username),
ReorderTemplateFolderCommand cmd => await HandleReorderTemplateFolder(sp, cmd, user.Username),
DeleteTemplateFolderCommand cmd => await HandleDeleteTemplateFolder(sp, cmd, user.Username),
MoveTemplateToFolderCommand cmd => await HandleMoveTemplateToFolder(sp, cmd, user.Username),
// Instances
ListInstancesCommand cmd => await HandleListInstances(sp, cmd, user),
GetInstanceCommand cmd => await HandleGetInstance(sp, cmd, user),
CreateInstanceCommand cmd => await HandleCreateInstance(sp, cmd, user),
MgmtDeployInstanceCommand cmd => await HandleDeployInstance(sp, cmd, user),
MgmtDeploySiteCommand cmd => await HandleDeploySite(sp, cmd, user),
MgmtEnableInstanceCommand cmd => await HandleEnableInstance(sp, cmd, user),
MgmtDisableInstanceCommand cmd => await HandleDisableInstance(sp, cmd, user),
MgmtDeleteInstanceCommand cmd => await HandleDeleteInstance(sp, cmd, user),
SetConnectionBindingsCommand cmd => await HandleSetConnectionBindings(sp, cmd, user),
SetInstanceOverridesCommand cmd => await HandleSetInstanceOverrides(sp, cmd, user),
SetInstanceAreaCommand cmd => await HandleSetInstanceArea(sp, cmd, user),
SetInstanceAlarmOverrideCommand cmd => await HandleSetInstanceAlarmOverride(sp, cmd, user),
DeleteInstanceAlarmOverrideCommand cmd => await HandleDeleteInstanceAlarmOverride(sp, cmd, user),
ListInstanceAlarmOverridesCommand cmd => await HandleListInstanceAlarmOverrides(sp, cmd, user),
SetInstanceNativeAlarmSourceOverrideCommand cmd => await HandleSetInstanceNativeAlarmSourceOverride(sp, cmd, user),
SetInstanceNativeAlarmSourceOverridesCommand cmd => await HandleSetInstanceNativeAlarmSourceOverrides(sp, cmd, user),
DeleteInstanceNativeAlarmSourceOverrideCommand cmd => await HandleDeleteInstanceNativeAlarmSourceOverride(sp, cmd, user),
ListInstanceNativeAlarmSourceOverridesCommand cmd => await HandleListInstanceNativeAlarmSourceOverrides(sp, cmd, user),
// Sites
ListSitesCommand => await HandleListSites(sp, user),
GetSiteCommand cmd => await HandleGetSite(sp, cmd, user),
CreateSiteCommand cmd => await HandleCreateSite(sp, cmd, user.Username),
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),
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),
GetDataConnectionCommand cmd => await HandleGetDataConnection(sp, cmd, user),
CreateDataConnectionCommand cmd => await HandleCreateDataConnection(sp, cmd, user.Username),
UpdateDataConnectionCommand cmd => await HandleUpdateDataConnection(sp, cmd, user.Username),
DeleteDataConnectionCommand cmd => await HandleDeleteDataConnection(sp, cmd, user.Username),
MoveDataConnectionCommand cmd => await HandleMoveDataConnection(sp, cmd, user.Username),
// Flow Debugger (design 2026-09-05 §7.1) — one execution path for the UI and the CLI.
FlowDebugRunMethodCommand cmd => await sp.GetRequiredService().RunMethodAsync(sp, cmd, user, CancellationToken.None),
FlowDebugRunValueChangeCommand cmd => await sp.GetRequiredService().RunValueChangeAsync(sp, cmd, user, CancellationToken.None),
FlowDebugRunScriptCommand cmd => await sp.GetRequiredService().RunScriptAsync(sp, cmd, user, CancellationToken.None),
FlowDebugReplayCommand cmd => await sp.GetRequiredService().ReplayAsync(sp, cmd, CancellationToken.None, user),
// External Systems
ListExternalSystemsCommand => await HandleListExternalSystems(sp),
GetExternalSystemCommand cmd => await HandleGetExternalSystem(sp, cmd),
CreateExternalSystemCommand cmd => await HandleCreateExternalSystem(sp, cmd, user.Username),
UpdateExternalSystemCommand cmd => await HandleUpdateExternalSystem(sp, cmd, user.Username),
DeleteExternalSystemCommand cmd => await HandleDeleteExternalSystem(sp, cmd, user.Username),
ListExternalSystemMethodsCommand cmd => await HandleListExternalSystemMethods(sp, cmd),
GetExternalSystemMethodCommand cmd => await HandleGetExternalSystemMethod(sp, cmd),
CreateExternalSystemMethodCommand cmd => await HandleCreateExternalSystemMethod(sp, cmd, user.Username),
UpdateExternalSystemMethodCommand cmd => await HandleUpdateExternalSystemMethod(sp, cmd, user.Username),
DeleteExternalSystemMethodCommand cmd => await HandleDeleteExternalSystemMethod(sp, cmd, user.Username),
// Notification Lists
ListNotificationListsCommand => await HandleListNotificationLists(sp),
GetNotificationListCommand cmd => await HandleGetNotificationList(sp, cmd),
CreateNotificationListCommand cmd => await HandleCreateNotificationList(sp, cmd, user.Username),
UpdateNotificationListCommand cmd => await HandleUpdateNotificationList(sp, cmd, user.Username),
DeleteNotificationListCommand cmd => await HandleDeleteNotificationList(sp, cmd, user.Username),
ListSmtpConfigsCommand => await HandleListSmtpConfigs(sp),
UpdateSmtpConfigCommand cmd => await HandleUpdateSmtpConfig(sp, cmd, user.Username),
ListSmsConfigsCommand => await HandleListSmsConfigs(sp),
UpdateSmsConfigCommand cmd => await HandleUpdateSmsConfig(sp, cmd, user.Username),
// Shared Scripts
ListSharedScriptsCommand => await HandleListSharedScripts(sp),
GetSharedScriptCommand cmd => await HandleGetSharedScript(sp, cmd),
CreateSharedScriptCommand cmd => await HandleCreateSharedScript(sp, cmd, user.Username),
UpdateSharedScriptCommand cmd => await HandleUpdateSharedScript(sp, cmd, user.Username),
DeleteSharedScriptCommand cmd => await HandleDeleteSharedScript(sp, cmd, user.Username),
// Schema Library
ListSharedSchemasCommand => await HandleListSharedSchemas(sp),
GetSharedSchemaCommand cmd => await HandleGetSharedSchema(sp, cmd),
CreateSharedSchemaCommand cmd => await HandleCreateSharedSchema(sp, cmd, user.Username),
UpdateSharedSchemaCommand cmd => await HandleUpdateSharedSchema(sp, cmd, user.Username),
DeleteSharedSchemaCommand cmd => await HandleDeleteSharedSchema(sp, cmd, user.Username),
// Database Connections (External System)
ListDatabaseConnectionsCommand => await HandleListDatabaseConnections(sp),
GetDatabaseConnectionCommand cmd => await HandleGetDatabaseConnection(sp, cmd),
CreateDatabaseConnectionDefCommand cmd => await HandleCreateDatabaseConnection(sp, cmd, user.Username),
UpdateDatabaseConnectionDefCommand cmd => await HandleUpdateDatabaseConnection(sp, cmd, user.Username),
DeleteDatabaseConnectionDefCommand cmd => await HandleDeleteDatabaseConnection(sp, cmd, user.Username),
// Inbound API Methods
ListApiMethodsCommand => await HandleListApiMethods(sp),
GetApiMethodCommand cmd => await HandleGetApiMethod(sp, cmd),
CreateApiMethodCommand cmd => await HandleCreateApiMethod(sp, cmd, user.Username),
UpdateApiMethodCommand cmd => await HandleUpdateApiMethod(sp, cmd, user.Username),
DeleteApiMethodCommand cmd => await HandleDeleteApiMethod(sp, cmd, user.Username),
// Security
ListRoleMappingsCommand => await HandleListRoleMappings(sp),
CreateRoleMappingCommand cmd => await HandleCreateRoleMapping(sp, cmd, user.Username),
UpdateRoleMappingCommand cmd => await HandleUpdateRoleMapping(sp, cmd, user.Username),
DeleteRoleMappingCommand cmd => await HandleDeleteRoleMapping(sp, cmd, user.Username),
ListApiKeysCommand => await HandleListApiKeys(sp),
CreateApiKeyCommand cmd => await HandleCreateApiKey(sp, cmd, user.Username),
DeleteApiKeyCommand cmd => await HandleDeleteApiKey(sp, cmd, user.Username),
UpdateApiKeyCommand cmd => await HandleUpdateApiKey(sp, cmd, user.Username),
SetApiKeyMethodsCommand cmd => await HandleSetApiKeyMethods(sp, cmd, user.Username),
ListScopeRulesCommand cmd => await HandleListScopeRules(sp, cmd),
AddScopeRuleCommand cmd => await HandleAddScopeRule(sp, cmd, user.Username),
DeleteScopeRuleCommand cmd => await HandleDeleteScopeRule(sp, cmd, user.Username),
// Deployments
MgmtDeployArtifactsCommand cmd => await HandleDeployArtifacts(sp, cmd, user),
QueryDeploymentsCommand cmd => await HandleQueryDeployments(sp, cmd, user),
GetDeploymentDiffCommand cmd => await HandleGetDeploymentDiff(sp, cmd, user),
// Audit Log
QueryAuditLogCommand cmd => await HandleQueryAuditLog(sp, cmd),
// Health
GetHealthSummaryCommand => HandleGetHealthSummary(sp),
GetSiteHealthCommand cmd => HandleGetSiteHealth(sp, cmd),
// Remote Queries
QueryEventLogsCommand cmd => await HandleQueryEventLogs(sp, cmd, user),
BrowseNodeCommand cmd => await HandleBrowseNode(sp, cmd, user),
SearchAddressSpaceCommand cmd => await HandleSearchAddressSpace(sp, cmd, user),
VerifyEndpointCommand cmd => await HandleVerifyEndpoint(sp, cmd, user),
TrustServerCertCommand cmd => await HandleTrustServerCert(sp, cmd, user),
RemoveServerCertCommand cmd => await HandleRemoveServerCert(sp, cmd, user),
ListServerCertsCommand cmd => await HandleListServerCerts(sp, cmd, user),
QueryParkedMessagesCommand cmd => await HandleQueryParkedMessages(sp, cmd, user),
RetryParkedMessageCommand cmd => await HandleRetryParkedMessage(sp, cmd, user),
DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd, user),
DebugSnapshotCommand cmd => await HandleDebugSnapshot(sp, cmd, user),
// Secured writes. Approve executes the write — once a
// Verifier wins the compare-and-swap the value is relayed to the site
// MxGateway connection.
SubmitSecuredWriteCommand cmd => await HandleSubmitSecuredWrite(sp, cmd, user),
ApproveSecuredWriteCommand cmd => await HandleApproveSecuredWrite(sp, cmd, user),
RejectSecuredWriteCommand cmd => await HandleRejectSecuredWrite(sp, cmd, user),
ListSecuredWritesCommand cmd => await HandleListSecuredWrites(sp, cmd, user),
// Transport bundle operations
ExportBundleCommand cmd => await HandleExportBundle(sp, cmd, user.Username),
PreviewBundleCommand cmd => await HandlePreviewBundle(sp, cmd, clientIp),
ImportBundleCommand cmd => await HandleImportBundle(sp, cmd, user.Username, clientIp),
// NOTE: ResolveRolesCommand is intentionally NOT dispatched. The two-step
// "ResolveRoles + command" flow is retired — the HTTP endpoint performs LDAP
// auth and role resolution itself before sending a single envelope. Leaving a
// handler would expose role-mapping data to any raw in-cluster sender with
// no role requirement; the command now falls through to the default below.
_ => throw new NotSupportedException($"Unknown command type: {command.GetType().Name}")
};
}
// ========================================================================
// Site-scope enforcement
// ========================================================================
///
/// Throws SiteScopeViolationException if the user has site-scoped Deployer
/// and the target site is not in their permitted sites.
/// Users with the Administrator role, or system-wide Deployer, are not restricted.
///
private static void EnforceSiteScope(AuthenticatedUser user, int? targetSiteId)
{
if (targetSiteId == null) return;
if (user.PermittedSiteIds.Length == 0) return; // system-wide access
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;
if (!user.PermittedSiteIds.Contains(targetSiteId.Value.ToString()))
{
throw new SiteScopeViolationException(
$"Access denied: your Deployment role is scoped to sites [{string.Join(", ", user.PermittedSiteIds)}] " +
$"and does not include site {targetSiteId.Value}.");
}
}
///
/// The fail-CLOSED arm of the scope gate: a target that could NOT be resolved is
/// denied for a site-scoped caller, and ignored for a system-wide one.
///
///
/// Register row 98. The scope gate used to be fail-OPEN on an unresolvable id —
/// the resolve-then-check shape was written as if (x != null) EnforceSiteScope(…) ,
/// so a null resolve skipped the check entirely and a site-scoped caller passed the
/// gate. Authorization now fails CLOSED: a target that cannot be PROVEN to sit
/// inside the caller's scope is treated as outside it.
///
///
/// System-wide callers are untouched. Administrator, or a Deployer with no
/// site grants (the rule documented on ), return at
/// the guards below — they were never scoped, so they still reach the handler and
/// still get its ordinary "not found"/null answer for a missing id.
///
///
///
/// The denial is deliberately indistinguishable from an out-of-scope one.
/// It raises the same (→
/// ManagementUnauthorized ), and must
/// never state whether the target exists — otherwise the gate becomes an
/// enumeration oracle letting a scoped caller probe ids across the whole fleet.
///
///
/// The authenticated caller.
/// Caller-safe description of the target, e.g. "instance 42" .
/// The caller is site-scoped.
private static void DenyUnresolvedTargetIfScoped(AuthenticatedUser user, string targetDescription)
{
if (user.PermittedSiteIds.Length == 0) return;
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;
throw new SiteScopeViolationException(
$"Access denied: {targetDescription} is not within your permitted sites " +
$"[{string.Join(", ", user.PermittedSiteIds)}].");
}
///
/// Resolves the site ID for an instance and enforces site-scope.
/// An instance that cannot be resolved is DENIED for a site-scoped caller
/// (see ).
///
private static async Task EnforceSiteScopeForInstance(IServiceProvider sp, AuthenticatedUser user, int instanceId)
{
if (user.PermittedSiteIds.Length == 0) return;
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;
var repo = sp.GetRequiredService();
var instance = await repo.GetInstanceByIdAsync(instanceId);
if (instance is null)
{
DenyUnresolvedTargetIfScoped(user, $"instance {instanceId}");
return;
}
EnforceSiteScope(user, instance.SiteId);
}
///
/// Resolves a site by its string identifier and enforces site-scope.
/// Used by remote-query handlers that key off the site identifier rather than its ID.
/// A site identifier that cannot be resolved is DENIED for a site-scoped caller
/// (see ).
///
private static async Task EnforceSiteScopeForIdentifier(IServiceProvider sp, AuthenticatedUser user, string siteIdentifier)
{
if (user.PermittedSiteIds.Length == 0) return;
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;
var repo = sp.GetRequiredService();
var site = await repo.GetSiteByIdentifierAsync(siteIdentifier);
if (site is null)
{
DenyUnresolvedTargetIfScoped(user, $"site '{siteIdentifier}'");
return;
}
EnforceSiteScope(user, site.Id);
}
///
/// Logs an audit entry after a successful mutation.
///
///
/// Audit-logging contract. Every mutating operation is
/// audited exactly once, by whichever layer owns the write:
///
/// - Handlers that mutate a repository directly (site, area, data-connection,
/// external-system, notification, security, API-key, scope-rule) call this helper
/// explicitly after the successful change.
/// - Handlers that delegate to a domain service (
TemplateService ,
/// SharedScriptService , InstanceService , AreaService ,
/// SiteService , TemplateFolderService , DeploymentService ,
/// ArtifactDeploymentService ) do NOT call this helper — those services own their
/// own dependency and audit internally. Adding an explicit
/// call in such a handler would double-log.
///
///
private static async Task AuditAsync(IServiceProvider sp, string user, string action, string entityType, string entityId, string entityName, object? afterState)
{
var auditService = sp.GetRequiredService();
await auditService.LogAsync(user, action, entityType, entityId, entityName, afterState);
}
// ========================================================================
// Template handlers
// ========================================================================
private static async Task HandleListTemplates(IServiceProvider sp, ListTemplatesCommand cmd)
{
var repo = sp.GetRequiredService();
// Paged and projected in the DATABASE. This used to load every template's
// full child graph — five Includes under AsSplitQuery, including every
// script body in the system — and then throw all but one page away in
// memory, so listing page 1 of 20 cost the same as listing everything.
// Child collections are reduced to counts, which is what every list
// surface (CLI table, UI tree) actually renders; a caller that needs a
// template's members already fetches it with GetTemplate.
return await repo.GetTemplateSummariesAsync(cmd.Skip, cmd.Take);
}
// Additive offset paging (arch-review P2). Take = null (or non-positive) keeps
// today's unlimited behaviour; Take is clamped to 1..1000; Skip floors at 0.
// Applied AFTER any in-memory scope filtering by the caller.
private static List Page(IEnumerable source, int skip, int? take) =>
source
.Skip(Math.Max(0, skip))
.Take(take is > 0 ? Math.Min(take.Value, 1000) : int.MaxValue)
.ToList();
private static async Task HandleGetTemplate(IServiceProvider sp, GetTemplateCommand cmd)
{
var repo = sp.GetRequiredService();
return await repo.GetTemplateWithChildrenAsync(cmd.TemplateId);
}
private static async Task HandleCreateTemplate(IServiceProvider sp, CreateTemplateCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var result = await svc.CreateTemplateAsync(cmd.Name, cmd.Description, cmd.ParentTemplateId, user);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task HandleUpdateTemplate(IServiceProvider sp, UpdateTemplateCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var result = await svc.UpdateTemplateAsync(cmd.TemplateId, cmd.Name, cmd.Description, cmd.ParentTemplateId, user);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task HandleDeleteTemplate(IServiceProvider sp, DeleteTemplateCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var result = await svc.DeleteTemplateAsync(cmd.TemplateId, user);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task HandleValidateTemplate(IServiceProvider sp, ValidateTemplateCommand cmd)
{
var repo = sp.GetRequiredService();
// Load the template with all members
var template = await repo.GetTemplateWithChildrenAsync(cmd.TemplateId)
?? throw new ManagementCommandException($"Template with ID {cmd.TemplateId} not found.");
var attributes = await repo.GetAttributesByTemplateIdAsync(cmd.TemplateId);
var alarms = await repo.GetAlarmsByTemplateIdAsync(cmd.TemplateId);
var scripts = await repo.GetScriptsByTemplateIdAsync(cmd.TemplateId);
// Build a FlattenedConfiguration from the template for the full validation pipeline
var flatConfig = new Commons.Types.Flattening.FlattenedConfiguration
{
InstanceUniqueName = $"validation-{template.Name}",
TemplateId = template.Id,
Attributes = attributes.Select(a => new Commons.Types.Flattening.ResolvedAttribute
{
CanonicalName = a.Name,
Value = a.Value,
DataType = a.DataType.ToString(),
ElementDataType = a.ElementDataType?.ToString(),
IsLocked = a.IsLocked,
DataSourceReference = a.DataSourceReference
}).ToList(),
Alarms = alarms.Select(a => new Commons.Types.Flattening.ResolvedAlarm
{
CanonicalName = a.Name,
PriorityLevel = a.PriorityLevel,
IsLocked = a.IsLocked,
TriggerType = a.TriggerType.ToString(),
TriggerConfiguration = a.TriggerConfiguration
}).ToList(),
Scripts = scripts.Select(s => new Commons.Types.Flattening.ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
IsLocked = s.IsLocked,
TriggerType = s.TriggerType,
TriggerConfiguration = s.TriggerConfiguration,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
}).ToList()
};
// Supply the JSON-Schema $ref resolution seam from the shared-schema
// library so a dangling {"$ref":"lib:Name"} in a template script schema is flagged
// here (design-time validate) consistently with the deploy path. The library is
// pre-loaded into a name→JSON map (the seam ValidationService consumes is sync).
var sharedSchemaRepo = sp.GetRequiredService();
var sharedSchemas = await sharedSchemaRepo.ListAsync();
var schemaLibrary = sharedSchemas.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
Func resolveSchemaRef = name => schemaLibrary.GetValueOrDefault(name);
// Also pass shared scripts so a dangling $ref in a SHARED script's schema is
// caught at design-time (not only on the deploy path). Mirror the deploy-path mapping
// in FlatteningPipeline: SharedScript entity → ResolvedScript (name + schema fields).
var sharedScriptEntities = await repo.GetAllSharedScriptsAsync();
var resolvedSharedScripts = sharedScriptEntities
.Select(s => new Commons.Types.Flattening.ResolvedScript
{
CanonicalName = s.Name,
Code = s.Code,
ParameterDefinitions = s.ParameterDefinitions,
ReturnDefinition = s.ReturnDefinition
})
.ToList();
// Run full validation pipeline (collisions, script compilation, trigger refs, bindings)
var validationService = new TemplateEngine.Validation.ValidationService();
var validationResult = validationService.Validate(flatConfig, resolvedSharedScripts, resolveSchemaRef: resolveSchemaRef);
// Also detect naming collisions across the inheritance/composition graph
var svc = sp.GetRequiredService();
var collisions = await svc.DetectCollisionsAsync(cmd.TemplateId);
if (collisions.Count > 0)
{
var collisionErrors = collisions.Select(c =>
Commons.Types.Flattening.ValidationEntry.Error(
Commons.Types.Flattening.ValidationCategory.NamingCollision, c)).ToArray();
var collisionResult = new Commons.Types.Flattening.ValidationResult { Errors = collisionErrors };
validationResult = Commons.Types.Flattening.ValidationResult.Merge(validationResult, collisionResult);
}
return validationResult;
}
///
/// Read-only authoring resolve: returns the effective inherited
/// member set for a template — computed fresh from the full inheritance
/// chain — plus a staleness summary. Loads every template (children
/// eager-loaded) so the resolver can walk an arbitrary-depth chain; never
/// mutates stored rows.
///
private static async Task HandleGetResolvedTemplateMembers(IServiceProvider sp, GetResolvedTemplateMembersCommand cmd)
{
var repo = sp.GetRequiredService();
var allTemplates = await repo.GetAllTemplatesAsync();
return TemplateInheritanceResolver.Resolve(cmd.TemplateId, allTemplates);
}
///
/// Resync inherited members: reconciles a template's stored
/// inherited rows (and its derived subtree's) with the resolved effective set —
/// materializing missing placeholders, re-syncing drifted ones, and removing
/// orphans — so the editor's editable tabs are complete and the staleness
/// banner clears. Designer-gated; the service owns the audit row.
///
private static async Task HandleResyncInheritedMembers(IServiceProvider sp, ResyncInheritedMembersCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var result = await svc.ResyncInheritedMembersAsync(cmd.TemplateId, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
// ========================================================================
// Template folder handlers
// ========================================================================
private static async Task HandleListTemplateFolders(IServiceProvider sp)
{
var repo = sp.GetRequiredService();
return await repo.GetAllFoldersAsync();
}
private static async Task HandleCreateTemplateFolder(IServiceProvider sp, CreateTemplateFolderCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var result = await svc.CreateFolderAsync(cmd.Name, cmd.ParentFolderId, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task HandleRenameTemplateFolder(IServiceProvider sp, RenameTemplateFolderCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var result = await svc.RenameFolderAsync(cmd.FolderId, cmd.NewName, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task HandleMoveTemplateFolder(IServiceProvider sp, MoveTemplateFolderCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var result = await svc.MoveFolderAsync(cmd.FolderId, cmd.NewParentFolderId, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task HandleReorderTemplateFolder(IServiceProvider sp, ReorderTemplateFolderCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var result = await svc.ReorderFolderAsync(cmd.FolderId, cmd.Direction, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task HandleDeleteTemplateFolder(IServiceProvider sp, DeleteTemplateFolderCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var result = await svc.DeleteFolderAsync(cmd.FolderId, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task HandleMoveTemplateToFolder(IServiceProvider sp, MoveTemplateToFolderCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var result = await svc.MoveTemplateAsync(cmd.TemplateId, cmd.NewFolderId, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
// ========================================================================
// Instance handlers
// ========================================================================
private static async Task HandleListInstances(IServiceProvider sp, ListInstancesCommand cmd, AuthenticatedUser user)
{
var repo = sp.GetRequiredService();
var instances = await repo.GetInstancesFilteredAsync(cmd.SiteId, cmd.TemplateId, cmd.SearchTerm);
// Filter by permitted sites for site-scoped users
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
{
var permittedIds = new HashSet(user.PermittedSiteIds);
instances = instances.Where(i => permittedIds.Contains(i.SiteId.ToString())).ToList();
}
// Paging is applied AFTER the site-scope filter so a scoped user never sees
// an out-of-scope instance surface into their page window.
return Page(instances, cmd.Skip, cmd.Take);
}
///
/// Reads one instance, scope-checked against its own site.
///
///
/// This handler resolves the instance ITSELF rather than calling
/// — the helper would repeat the same
/// repository read — so it carries its own copy of the fail-CLOSED arm
/// (register row 98). A scoped caller must not be able to tell a nonexistent
/// instance (which used to answer success/null) from one at a site they may not
/// see, or the read becomes an id-enumeration oracle over the whole fleet.
///
private static async Task HandleGetInstance(IServiceProvider sp, GetInstanceCommand cmd, AuthenticatedUser user)
{
var repo = sp.GetRequiredService();
var instance = await repo.GetInstanceByIdAsync(cmd.InstanceId);
if (instance is null)
{
DenyUnresolvedTargetIfScoped(user, $"instance {cmd.InstanceId}");
return null;
}
EnforceSiteScope(user, instance.SiteId);
return instance;
}
private static async Task HandleCreateInstance(IServiceProvider sp, CreateInstanceCommand cmd, AuthenticatedUser user)
{
EnforceSiteScope(user, cmd.SiteId);
var svc = sp.GetRequiredService();
var result = await svc.CreateInstanceAsync(cmd.UniqueName, cmd.TemplateId, cmd.SiteId, cmd.AreaId, user.Username);
if (!result.IsSuccess) throw new ManagementCommandException(result.Error);
await AuditAsync(sp, user.Username, "Create", "Instance", result.Value.Id.ToString(), result.Value.UniqueName, result.Value);
return result.Value;
}
private static async Task HandleDeployInstance(IServiceProvider sp, MgmtDeployInstanceCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var svc = sp.GetRequiredService();
var result = await svc.DeployInstanceAsync(cmd.InstanceId, user.Username);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
///
/// Bulk-deploys every deployable instance at a site.
///
///
/// Site scope is enforced on the SITE (not per instance) because the site is
/// the command's target: a site-scoped Deployer may bulk-deploy only sites in
/// their scope, and every instance the batch touches belongs to that site by
/// construction — so the per-instance check would be redundant.
///
///
///
/// Force is relayed verbatim: unset (the default) the batch skips
/// instances whose config already matches the deployed revision, set it
/// redeploys them anyway. It carries no extra authorization — the command's
/// own Deployer + site scope already gates the whole operation, and forcing
/// only widens it to instances this same caller could deploy one at a time.
///
///
private static async Task HandleDeploySite(IServiceProvider sp, MgmtDeploySiteCommand cmd, AuthenticatedUser user)
{
EnforceSiteScope(user, cmd.SiteId);
var svc = sp.GetRequiredService();
var timeout = sp.GetService>()?.Value.LongRunningCommandTimeout ?? TimeSpan.FromMinutes(5);
using var deadline = new CancellationTokenSource(timeout);
try
{
var result = await svc.DeploySiteAsync(cmd.SiteId, user.Username, cmd.Force, deadline.Token);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
catch (OperationCanceledException) when (deadline.IsCancellationRequested)
{
throw new ManagementCommandException("Overall bulk deployment deadline exceeded before further apply could be scheduled.", "BULK_DEPLOY_TIMEOUT");
}
}
private static async Task HandleEnableInstance(IServiceProvider sp, MgmtEnableInstanceCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var svc = sp.GetRequiredService();
var result = await svc.EnableInstanceAsync(cmd.InstanceId, user.Username);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task HandleDisableInstance(IServiceProvider sp, MgmtDisableInstanceCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var svc = sp.GetRequiredService();
var result = await svc.DisableInstanceAsync(cmd.InstanceId, user.Username);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task HandleDeleteInstance(IServiceProvider sp, MgmtDeleteInstanceCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var svc = sp.GetRequiredService();
var result = await svc.DeleteInstanceAsync(cmd.InstanceId, user.Username);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task HandleSetConnectionBindings(IServiceProvider sp, SetConnectionBindingsCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var svc = sp.GetRequiredService();
var result = await svc.SetConnectionBindingsAsync(cmd.InstanceId, cmd.Bindings, user.Username);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task HandleSetInstanceOverrides(IServiceProvider sp, SetInstanceOverridesCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
// Multi-override apply is all-or-nothing.
// InstanceService.SetAttributeOverrideAsync commits each override
// independently, so a mid-batch failure on an invalid attribute would
// otherwise leave the instance partially mutated. Validate every
// requested attribute up front against the instance's template; only
// apply once the whole batch is known to be valid. (A genuine database
// fault mid-apply remains theoretically possible without a shared
// transaction, but the realistic failure modes — unknown or locked
// attribute — are now rejected before any write.)
var repo = sp.GetRequiredService();
var instance = await repo.GetInstanceByIdAsync(cmd.InstanceId)
?? throw new ManagementCommandException($"Instance with ID {cmd.InstanceId} not found.");
var templateAttrs = await repo.GetAttributesByTemplateIdAsync(instance.TemplateId);
var attrsByName = templateAttrs.ToDictionary(a => a.Name);
foreach (var attrName in cmd.Overrides.Keys)
{
if (!attrsByName.TryGetValue(attrName, out var templateAttr))
throw new ManagementCommandException(
$"Attribute '{attrName}' does not exist in template {instance.TemplateId}. " +
"No overrides were applied.");
if (templateAttr.IsLocked)
throw new ManagementCommandException(
$"Attribute '{attrName}' is locked and cannot be overridden. No overrides were applied.");
}
// Apply as ONE read + ONE commit. The previous loop called
// InstanceService.SetAttributeOverrideAsync per entry, and each of those
// re-read the instance, re-read the template's attributes, re-read the
// instance's existing overrides, then committed and wrote an audit row —
// K read-modify-commit cycles for what is logically one batch. That is
// both K× the round trips and K× the windows in which a mid-batch fault
// can leave the instance partially mutated, which is exactly what the
// pre-validation above was bolted on to mitigate.
//
// Now: the existing override rows are read once in bulk, every entry is
// added or updated against that snapshot, and a single SaveChangesAsync
// commits the whole batch — making the all-or-nothing promise real rather
// than best-effort. One audit row summarises the batch.
var existingOverrides = await repo.GetOverridesByInstanceIdAsync(cmd.InstanceId);
var existingByName = existingOverrides
.GroupBy(o => o.AttributeName, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
var results = new List(cmd.Overrides.Count);
foreach (var (attrName, overrideValue) in cmd.Overrides)
{
// attrsByName lookup is guaranteed to hit — the pre-validation loop
// above rejects the whole batch on an unknown or locked attribute.
var elementDataType = attrsByName[attrName].ElementDataType;
if (existingByName.TryGetValue(attrName, out var existing))
{
existing.OverrideValue = overrideValue;
existing.ElementDataType = elementDataType;
await repo.UpdateInstanceAttributeOverrideAsync(existing);
results.Add(existing);
}
else
{
var created = new InstanceAttributeOverride(attrName)
{
InstanceId = cmd.InstanceId,
OverrideValue = overrideValue,
ElementDataType = elementDataType
};
await repo.AddInstanceAttributeOverrideAsync(created);
results.Add(created);
}
}
await repo.SaveChangesAsync();
var auditService = sp.GetRequiredService();
await auditService.LogAsync(
user.Username, "SetOverrides", "Instance", cmd.InstanceId.ToString(), instance.UniqueName,
new { Count = results.Count, Attributes = cmd.Overrides.Keys.ToArray() });
return results;
}
private static async Task HandleSetInstanceArea(IServiceProvider sp, SetInstanceAreaCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var svc = sp.GetRequiredService();
var result = await svc.AssignToAreaAsync(cmd.InstanceId, cmd.AreaId, user.Username);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task HandleSetInstanceAlarmOverride(IServiceProvider sp, SetInstanceAlarmOverrideCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var svc = sp.GetRequiredService();
var result = await svc.SetAlarmOverrideAsync(
cmd.InstanceId, cmd.AlarmCanonicalName,
cmd.TriggerConfigurationOverride, cmd.PriorityLevelOverride,
user.Username, cmd.IsMesReportableOverride);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task HandleDeleteInstanceAlarmOverride(IServiceProvider sp, DeleteInstanceAlarmOverrideCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var svc = sp.GetRequiredService();
var result = await svc.DeleteAlarmOverrideAsync(
cmd.InstanceId, cmd.AlarmCanonicalName, user.Username);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task HandleListInstanceAlarmOverrides(IServiceProvider sp, ListInstanceAlarmOverridesCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var repo = sp.GetRequiredService();
return await repo.GetAlarmOverridesByInstanceIdAsync(cmd.InstanceId);
}
private static async Task HandleSetInstanceNativeAlarmSourceOverride(
IServiceProvider sp, SetInstanceNativeAlarmSourceOverrideCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var repo = sp.GetRequiredService();
// Enforce template-level locks (and reject dangling overrides) before persisting.
// Flatten the instance (script compilation skipped — a lock check does not need it)
// and look up the resolved source by canonical name: a locked source cannot be
// overridden, and an unresolved name would create a dead override the flattener
// silently drops.
var pipeline = sp.GetRequiredService();
var flattenResult = await pipeline.FlattenAndValidateAsync(
cmd.InstanceId, default, validateScripts: false);
if (flattenResult.IsFailure)
throw new ManagementCommandException(
$"Cannot set native alarm source override: the instance could not be flattened ({flattenResult.Error}).");
var resolvedSource = flattenResult.Value.Configuration.NativeAlarmSources
.FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.SourceCanonicalName, StringComparison.Ordinal));
if (resolvedSource == null)
throw new ManagementCommandException(
$"Native alarm source '{cmd.SourceCanonicalName}' does not resolve for this instance and cannot be overridden.");
if (resolvedSource.IsLocked)
throw new ManagementCommandException(
$"Native alarm source '{cmd.SourceCanonicalName}' is locked at the template level and cannot be overridden.");
var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, cmd.SourceCanonicalName);
if (existing == null)
{
var ovr = new InstanceNativeAlarmSourceOverride(cmd.SourceCanonicalName)
{
InstanceId = cmd.InstanceId,
ConnectionNameOverride = cmd.ConnectionNameOverride,
SourceReferenceOverride = cmd.SourceReferenceOverride,
ConditionFilterOverride = cmd.ConditionFilterOverride
};
await repo.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
await repo.SaveChangesAsync();
return ovr;
}
existing.ConnectionNameOverride = cmd.ConnectionNameOverride;
existing.SourceReferenceOverride = cmd.SourceReferenceOverride;
existing.ConditionFilterOverride = cmd.ConditionFilterOverride;
await repo.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
await repo.SaveChangesAsync();
return existing;
}
private static async Task HandleSetInstanceNativeAlarmSourceOverrides(
IServiceProvider sp, SetInstanceNativeAlarmSourceOverridesCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var repo = sp.GetRequiredService();
// All-or-nothing (parity with HandleSetInstanceOverrides). Flatten the
// instance ONCE (script compilation skipped — a lock/resolve check does not
// need it) and validate EVERY requested source up front: a source that does
// not resolve would create a dead override the flattener silently drops, and a
// template-locked source cannot be overridden. Only once the whole batch is
// known valid do we upsert — a mid-batch reject never leaves partial state.
var pipeline = sp.GetRequiredService();
var flattenResult = await pipeline.FlattenAndValidateAsync(
cmd.InstanceId, default, validateScripts: false);
if (flattenResult.IsFailure)
throw new ManagementCommandException(
$"Cannot set native alarm source overrides: the instance could not be flattened ({flattenResult.Error}).");
var resolvedByName = flattenResult.Value.Configuration.NativeAlarmSources
.ToDictionary(s => s.CanonicalName, StringComparer.Ordinal);
// Reject a batch that names the same source twice — the intended override
// would be ambiguous, and the last-writer-wins upsert would silently discard
// the earlier row.
var seen = new HashSet(StringComparer.Ordinal);
foreach (var entry in cmd.Overrides)
{
if (!resolvedByName.TryGetValue(entry.SourceCanonicalName, out var resolvedSource))
throw new ManagementCommandException(
$"Native alarm source '{entry.SourceCanonicalName}' does not resolve for this instance " +
"and cannot be overridden. No overrides were applied.");
if (resolvedSource.IsLocked)
throw new ManagementCommandException(
$"Native alarm source '{entry.SourceCanonicalName}' is locked at the template level and " +
"cannot be overridden. No overrides were applied.");
if (!seen.Add(entry.SourceCanonicalName))
throw new ManagementCommandException(
$"Native alarm source '{entry.SourceCanonicalName}' appears more than once in the batch. " +
"No overrides were applied.");
}
var results = new List();
foreach (var entry in cmd.Overrides)
{
var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, entry.SourceCanonicalName);
if (existing == null)
{
var ovr = new InstanceNativeAlarmSourceOverride(entry.SourceCanonicalName)
{
InstanceId = cmd.InstanceId,
ConnectionNameOverride = entry.ConnectionNameOverride,
SourceReferenceOverride = entry.SourceReferenceOverride,
ConditionFilterOverride = entry.ConditionFilterOverride
};
await repo.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
results.Add(ovr);
}
else
{
existing.ConnectionNameOverride = entry.ConnectionNameOverride;
existing.SourceReferenceOverride = entry.SourceReferenceOverride;
existing.ConditionFilterOverride = entry.ConditionFilterOverride;
await repo.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
results.Add(existing);
}
}
await repo.SaveChangesAsync();
return results;
}
private static async Task HandleDeleteInstanceNativeAlarmSourceOverride(
IServiceProvider sp, DeleteInstanceNativeAlarmSourceOverrideCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var repo = sp.GetRequiredService();
var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, cmd.SourceCanonicalName);
if (existing != null)
{
await repo.DeleteInstanceNativeAlarmSourceOverrideAsync(existing.Id);
await repo.SaveChangesAsync();
}
return cmd.SourceCanonicalName;
}
private static async Task HandleListInstanceNativeAlarmSourceOverrides(
IServiceProvider sp, ListInstanceNativeAlarmSourceOverridesCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var repo = sp.GetRequiredService();
return await repo.GetNativeAlarmSourceOverridesByInstanceIdAsync(cmd.InstanceId);
}
private static async Task HandleGetDeploymentDiff(IServiceProvider sp, GetDeploymentDiffCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var svc = sp.GetRequiredService();
var result = await svc.GetDeploymentComparisonAsync(cmd.InstanceId);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task HandleRetryParkedMessage(IServiceProvider sp, RetryParkedMessageCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);
var commService = sp.GetRequiredService();
var request = new Commons.Messages.RemoteQuery.ParkedMessageRetryRequest(
Guid.NewGuid().ToString("N"), cmd.SiteIdentifier, cmd.MessageId, DateTimeOffset.UtcNow);
return await commService.RetryParkedMessageAsync(cmd.SiteIdentifier, request);
}
private static async Task HandleDiscardParkedMessage(IServiceProvider sp, DiscardParkedMessageCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);
var commService = sp.GetRequiredService();
var request = new Commons.Messages.RemoteQuery.ParkedMessageDiscardRequest(
Guid.NewGuid().ToString("N"), cmd.SiteIdentifier, cmd.MessageId, DateTimeOffset.UtcNow);
return await commService.DiscardParkedMessageAsync(cmd.SiteIdentifier, request);
}
// ========================================================================
// Secured-write handlers
//
// Two-person workflow: an Operator submits a pending write against an
// MxGateway data connection; a distinct Verifier approves or
// rejects it. The role gate (GetRequiredRole) only verifies the coarse role;
// the separation-of-duties rule (a write may not be verified by its
// submitter) is enforced here.
// ========================================================================
private static SecuredWriteDto ToSecuredWriteDto(PendingSecuredWrite e) => new(
e.Id, e.SiteId, e.ConnectionName, e.TagPath, e.ValueJson, e.ValueType,
e.Status, e.OperatorUser, e.OperatorComment, e.SubmittedAtUtc,
e.VerifierUser, e.VerifierComment, e.DecidedAtUtc, e.ExecutedAtUtc, e.ExecutionError);
///
/// Deterministic, reversible map from a (a
/// store-assigned ) to the canonical AuditLog
/// CorrelationId (a ): the 8-byte big-endian id occupies
/// the final 8 bytes of an otherwise-zero Guid. Every row across one secured-write
/// lifecycle (submit → approve → execute, or submit → reject) shares this value so
/// they join into one operation; the encoding is stable (same id ⇒ same Guid).
///
private static Guid SecuredWriteCorrelation(long id)
{
Span bytes = stackalloc byte[16];
BinaryPrimitives.WriteInt64BigEndian(bytes[8..], id);
return new Guid(bytes);
}
///
/// Best-effort emission of ONE secured-write AuditLog row via the central direct-write
/// path ( ) — mirrors the Notification
/// Outbox dispatch / Inbound API central-origin pattern. The row is built through the
/// canonical so Action/Category/Outcome map
/// identically to every other emit site.
///
///
/// Routes through (rather than calling
/// directly) so the writing
/// central node's SourceNode (central-a /central-b ) is stamped via
/// the writer's — satisfying the design's
/// node-of-origin invariant. The rows are otherwise unchanged (same channel/kind,
/// same CorrelationId = the row id, same payload).
///
/// Standing audit invariant: an audit-write failure NEVER aborts the secured-write
/// action. is best-effort and swallows its own
/// internal failures; the surrounding try/catch is defensive belt-and-braces (it also
/// guards writer resolution) — either way the caller's own success/failure path is
/// authoritative.
///
///
private static async Task EmitSecuredWriteAuditAsync(
IServiceProvider sp,
AuditKind kind,
AuditStatus status,
PendingSecuredWrite row,
string actor,
string? errorMessage = null)
{
try
{
var evt = ScadaBridgeAuditEventFactory.Create(
channel: AuditChannel.SecuredWrite,
kind: kind,
status: status,
actor: actor,
target: $"{row.SiteId}/{row.ConnectionName}/{row.TagPath}",
correlationId: SecuredWriteCorrelation(row.Id),
sourceSiteId: row.SiteId,
errorMessage: errorMessage,
// Carry the counterparty (operator on a verifier-actioned row, and
// vice-versa) so a single row names both parties to the two-person write.
extra: JsonSerializer.Serialize(new
{
operatorUser = row.OperatorUser,
verifierUser = row.VerifierUser
}));
// The central direct-write writer is a singleton that opens its own
// per-call scope for the (scoped) IAuditLogRepository and stamps SourceNode
// from the local INodeIdentityProvider — no scope needed here.
var auditWriter = sp.GetRequiredService();
await auditWriter.WriteAsync(evt);
}
catch (Exception ex)
{
// Audit is best-effort — swallow + log; never abort the secured-write action.
sp.GetService>()?.LogWarning(
ex,
"Best-effort secured-write audit emission failed (kind={Kind}, id={Id}); the write itself is unaffected.",
kind, row.Id);
}
}
/// If the Pending row is older than the configured TTL, CAS it to Expired
/// (multi-node idempotent), audit best-effort, and return true. A stale setpoint
/// must never be approvable days later (arch-review S2).
private static async Task TryExpireIfStaleAsync(
IServiceProvider sp, ISecuredWriteRepository repo, PendingSecuredWrite row)
{
var ttl = sp.GetService>()?.Value.SecuredWritePendingTtl
?? TimeSpan.FromHours(24);
if (ttl <= TimeSpan.Zero || DateTime.UtcNow - row.SubmittedAtUtc <= ttl) return false;
var expiredAt = DateTime.UtcNow;
if (await repo.TryMarkExpiredAsync(row.Id, expiredAt))
{
row.Status = "Expired";
row.DecidedAtUtc = expiredAt;
// No Discarded-equivalent below Expired in the AuditStatus enum; Discarded is
// the closest terminal status (the write was abandoned, never executed).
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteExpire, AuditStatus.Discarded, row, actor: "system");
}
return true;
}
private static async Task HandleSubmitSecuredWrite(
IServiceProvider sp, SubmitSecuredWriteCommand cmd, AuthenticatedUser user)
{
// Site scope (arch-review R2 N3): an LDAP-mapping-scoped Operator may only
// submit device writes to their permitted sites — same rule C2 applies to
// DeployArtifacts, on a higher-consequence path. Checked before the row exists.
//
// The unresolvable case is fail-CLOSED and answered BEFORE the "not found"
// (arch-review 2026-08-19 P6-5): resolving first and reporting not-found made
// this handler a site-identifier existence oracle for an out-of-scope caller,
// who now gets the same scope denial whether or not the site exists.
var siteRepo = sp.GetRequiredService();
var site = await siteRepo.GetSiteByIdentifierAsync(cmd.SiteId);
if (site is null)
{
DenyUnresolvedTargetIfScoped(user, $"site '{cmd.SiteId}'");
throw new ManagementCommandException($"Site '{cmd.SiteId}' not found.");
}
EnforceSiteScope(user, site.Id);
var connections = await siteRepo.GetDataConnectionsBySiteIdAsync(site.Id);
var conn = connections.FirstOrDefault(c =>
string.Equals(c.Name, cmd.ConnectionName, StringComparison.Ordinal))
?? throw new ManagementCommandException(
$"Data connection '{cmd.ConnectionName}' not found on site '{cmd.SiteId}'.");
// Secured writes only apply to MxGateway connections.
if (!string.Equals(conn.Protocol, "MxGateway", StringComparison.OrdinalIgnoreCase))
throw new ManagementCommandException(
$"Secured writes require an MxGateway connection; '{cmd.ConnectionName}' uses protocol '{conn.Protocol}'.");
var entity = new PendingSecuredWrite
{
SiteId = cmd.SiteId,
ConnectionName = cmd.ConnectionName,
TagPath = cmd.TagPath,
ValueJson = cmd.ValueJson,
ValueType = cmd.ValueType,
Status = "Pending",
OperatorUser = user.Username,
OperatorComment = cmd.Comment,
SubmittedAtUtc = DateTime.UtcNow
};
var repo = sp.GetRequiredService();
entity.Id = await repo.AddAsync(entity);
// One append-only audit row per lifecycle event. Emitted AFTER the row is
// persisted (so it carries the store-assigned id); best-effort — see helper.
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteSubmit, AuditStatus.Submitted, entity, actor: entity.OperatorUser);
return ToSecuredWriteDto(entity);
}
private static async Task HandleApproveSecuredWrite(
IServiceProvider sp, ApproveSecuredWriteCommand cmd, AuthenticatedUser user)
{
var repo = sp.GetRequiredService();
var row = await repo.GetAsync(cmd.Id);
if (row is null)
{
DenyUnresolvedTargetIfScoped(user, $"secured write {cmd.Id}");
throw new ManagementCommandException($"Secured write {cmd.Id} not found.");
}
// Site scope (arch-review R2 N3): the approving Verifier must be permitted on
// the row's target site — checked BEFORE the TTL/self-approval/CAS chain so an
// out-of-scope verifier can neither consume the Pending transition nor relay.
//
// It is also checked BEFORE the status test (arch-review 2026-08-19 P6-5): the
// "is 'Executed', not Pending" message told an out-of-scope caller both that the
// id existed and what had happened to it. An out-of-scope caller now gets the
// same scope denial for every id, existing or not.
await EnforceSiteScopeForIdentifier(sp, user, row.SiteId);
if (!string.Equals(row.Status, "Pending", StringComparison.Ordinal))
throw new ManagementCommandException(
$"Secured write {cmd.Id} is '{row.Status}', not Pending; it cannot be approved.");
// Server-side TTL: a Pending write older than the configured age is transitioned
// to Expired and can never be approved — the stale setpoint is never relayed
// (arch-review S2). Checked BEFORE the self-approval/CAS so an overdue row never
// executes, regardless of who touches it.
if (await TryExpireIfStaleAsync(sp, repo, row))
throw new ManagementCommandException(
$"Secured write {cmd.Id} expired (pending longer than the configured TTL) and can no longer be approved or rejected.");
// Separation of duties: a write may not be verified by its submitter. Checked
// BEFORE the CAS so a self-approval never consumes the Pending->Approved
// transition.
if (string.Equals(row.OperatorUser, user.Username, StringComparison.OrdinalIgnoreCase))
throw new ManagementCommandException(
"A secured write cannot be verified by its submitter.");
// Compare-and-swap: guards the two-verifier race. Exactly one concurrent
// approver flips Pending->Approved; the loser observes false here and must
// not relay the write.
var decidedAtUtc = DateTime.UtcNow;
if (!await repo.TryMarkApprovedAsync(cmd.Id, user.Username, cmd.Comment, decidedAtUtc))
throw new ManagementCommandException(
$"Secured write {cmd.Id} is no longer pending — already decided.");
// We won the race. Stamp the verifier decision locally so the entity we
// persist below carries the same values the CAS committed.
row.Status = "Approved";
row.VerifierUser = user.Username;
row.VerifierComment = cmd.Comment;
row.DecidedAtUtc = decidedAtUtc;
// The approval decision is itself an audited lifecycle event (the
// verifier won the CAS). Emitted with the in-flight Submitted status; the
// Execute row below records the terminal write outcome.
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteApprove, AuditStatus.Submitted, row, actor: user.Username);
// TOCTOU guard: re-assert the MxGateway protocol AT EXECUTE, not only at
// submit. The connection named on the row may have been reconfigured/recreated as a
// non-MxGateway (e.g. OPC UA) connection between submit and approval; relaying then
// would execute the secured write against a non-MxGateway adapter, violating the
// feature's core safety invariant. Re-load the connection with the same lookup
// submit uses (resolve site -> its data connections -> match by name) and fail the
// row deterministically (mirrors the decode-error containment below) if it is
// missing or no longer MxGateway — WITHOUT relaying.
var siteRepo = sp.GetRequiredService();
var execSite = await siteRepo.GetSiteByIdentifierAsync(row.SiteId);
var execConn = execSite is null
? null
: (await siteRepo.GetDataConnectionsBySiteIdAsync(execSite.Id))
.FirstOrDefault(c => string.Equals(c.Name, row.ConnectionName, StringComparison.Ordinal));
if (execConn is null ||
!string.Equals(execConn.Protocol, "MxGateway", StringComparison.OrdinalIgnoreCase))
{
row.Status = "Failed";
row.ExecutedAtUtc = DateTime.UtcNow;
row.ExecutionError = execConn is null
? $"connection '{row.ConnectionName}' not found"
: $"connection '{row.ConnectionName}' is no longer an MxGateway connection";
await repo.UpdateAsync(row);
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteExecute, AuditStatus.Failed, row,
actor: user.Username, errorMessage: row.ExecutionError);
return ToSecuredWriteDto(row);
}
// Validate the value type BEFORE attempting the relay. An unknown type can
// never be decoded/written, so fail the row deterministically rather than
// leaving it stuck Approved. (Addresses the reviewer's deferred
// ValueType-validation note.)
if (!Enum.TryParse(row.ValueType, ignoreCase: true, out var dataType))
{
row.Status = "Failed";
row.ExecutedAtUtc = DateTime.UtcNow;
row.ExecutionError = "unknown value type";
await repo.UpdateAsync(row);
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteExecute, AuditStatus.Failed, row,
actor: user.Username, errorMessage: row.ExecutionError);
return ToSecuredWriteDto(row);
}
// Robustness fix: Decode is UNGUARDED without a try/catch — a List-typed
// value carrying corrupt JSON throws out of the handler and leaves the row
// stuck Approved. Contain it: fail the row deterministically with the decode
// error, audit the failure, and return WITHOUT relaying (nothing to write).
object? value;
try
{
value = Commons.Types.AttributeValueCodec.Decode(row.ValueJson, dataType, elementType: null);
}
catch (Exception ex)
{
row.Status = "Failed";
row.ExecutedAtUtc = DateTime.UtcNow;
row.ExecutionError = $"value decode error: {ex.Message}";
await repo.UpdateAsync(row);
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteExecute, AuditStatus.Failed, row,
actor: user.Username, errorMessage: row.ExecutionError);
return ToSecuredWriteDto(row);
}
// Relay the write to the site MxGateway connection. A transport exception is
// contained so the row is never left stuck Approved.
var commService = sp.GetRequiredService();
bool success;
string? error;
try
{
var resp = await commService.WriteTagAsync(
row.SiteId,
new Commons.Messages.DataConnection.WriteTagRequest(
CorrelationId: Guid.NewGuid().ToString("N"),
ConnectionName: row.ConnectionName,
TagPath: row.TagPath,
Value: value,
Timestamp: DateTimeOffset.UtcNow));
success = resp.Success;
error = resp.Success ? null : resp.ErrorMessage;
}
catch (Exception ex)
{
success = false;
error = ex.Message;
}
row.Status = success ? "Executed" : "Failed";
row.ExecutedAtUtc = DateTime.UtcNow;
row.ExecutionError = error;
// UpdateAsync overwrites all columns -> pass the fully-populated entity.
// Safe as a blind write (arch-review 2026-08-19 P6-1): winning the
// Pending->Approved CAS above gives this leg EXCLUSIVE ownership of the row from
// that point on. Every other writer transitions only out of Pending —
// TryMarkApprovedAsync, TryMarkRejectedAsync and TryMarkExpiredAsync (both the
// approve/reject inline expiry and the opportunistic list sweep) are all
// conditional on Status='Pending' — so nothing can overwrite the row while the
// multi-second relay above is in flight. Do not reintroduce an unconditional
// transition on this table.
await repo.UpdateAsync(row);
// Terminal execute outcome: Delivered (relay succeeded) maps to canonical
// Success, Failed maps to canonical Failure (the error rides in the row detail).
await EmitSecuredWriteAuditAsync(
sp,
AuditKind.SecuredWriteExecute,
success ? AuditStatus.Delivered : AuditStatus.Failed,
row,
actor: user.Username,
errorMessage: error);
return ToSecuredWriteDto(row);
}
private static async Task HandleRejectSecuredWrite(
IServiceProvider sp, RejectSecuredWriteCommand cmd, AuthenticatedUser user)
{
var repo = sp.GetRequiredService();
var entity = await repo.GetAsync(cmd.Id);
if (entity is null)
{
DenyUnresolvedTargetIfScoped(user, $"secured write {cmd.Id}");
throw new ManagementCommandException($"Secured write {cmd.Id} not found.");
}
// Site scope (arch-review R2 N3): the rejecting Verifier must be permitted on
// the row's target site — checked BEFORE the TTL/self-approval chain, and (as on
// approve) BEFORE the status test, so the not-found/status errors stop being an
// existence-and-status oracle for an out-of-scope caller (arch-review 2026-08-19
// P6-5). The CAS below is unchanged.
await EnforceSiteScopeForIdentifier(sp, user, entity.SiteId);
if (!string.Equals(entity.Status, "Pending", StringComparison.Ordinal))
throw new ManagementCommandException(
$"Secured write {cmd.Id} is '{entity.Status}', not Pending; it cannot be rejected.");
// Server-side TTL: an overdue Pending write expires here too, so a reject on a
// stale row deterministically reports the expiry rather than recording a
// human decision (arch-review S2).
if (await TryExpireIfStaleAsync(sp, repo, entity))
throw new ManagementCommandException(
$"Secured write {cmd.Id} expired (pending longer than the configured TTL) and can no longer be approved or rejected.");
// Separation of duties: the verifier must differ from the submitter.
if (string.Equals(entity.OperatorUser, user.Username, StringComparison.OrdinalIgnoreCase))
throw new ManagementCommandException(
"A secured write cannot be verified by its submitter.");
// Compare-and-swap: guards the approve-vs-reject race exactly as approve's own
// CAS guards the two-verifier race (arch-review 2026-08-19 P6-1). The status
// check above is a stale read — commands run concurrently, each on its own DI
// scope and across both central nodes on one shared MS SQL — so a concurrent
// approve can win its CAS and enter its multi-second WriteTagAsync relay in the
// gap. The blind full-row UpdateAsync that used to sit here would then land
// mid-relay and stamp the row Rejected while the plant write physically
// executed, falsifying the two-person-control record. Reject now transitions
// ONLY from Pending; the loser records nothing.
var decidedAtUtc = DateTime.UtcNow;
if (!await repo.TryMarkRejectedAsync(cmd.Id, user.Username, cmd.Comment, decidedAtUtc))
{
// Re-read so the conflict names the status that actually won (approve,
// another reject, or the TTL expiry) — same UX shape as the stale-read
// guard above and approve's own lost-race error.
var current = await repo.GetAsync(cmd.Id);
throw new ManagementCommandException(current is null
? $"Secured write {cmd.Id} is no longer pending — already decided."
: $"Secured write {cmd.Id} is '{current.Status}', not Pending; it cannot be rejected.");
}
// We won the race. Stamp the decision locally so the returned DTO carries the
// same values the CAS committed.
entity.Status = "Rejected";
entity.VerifierUser = user.Username;
entity.VerifierComment = cmd.Comment;
entity.DecidedAtUtc = decidedAtUtc;
// Reject is a terminal lifecycle event (canonical Discarded outcome). Emitted
// ONLY on a won CAS, so a lost race never leaves a reject row in the audit log
// for a write that was actually approved and executed.
// Actor = the verifier; the operator is carried in the row's extra detail.
await EmitSecuredWriteAuditAsync(
sp, AuditKind.SecuredWriteReject, AuditStatus.Discarded, entity, actor: user.Username);
return ToSecuredWriteDto(entity);
}
private static async Task HandleListSecuredWrites(
IServiceProvider sp, ListSecuredWritesCommand cmd, AuthenticatedUser user)
{
// Site scoping (arch-review R2 N3): an explicit SiteId filter is scope-checked
// like every other identifier-keyed command; an UNFILTERED list from a
// site-scoped non-admin caller is constrained to their permitted sites at the
// query level (rows AND totalCount both scoped — see ISecuredWriteRepository).
IReadOnlyCollection? permittedIdentifiers = null;
if (cmd.SiteId is not null)
{
await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteId);
}
else if (user.PermittedSiteIds.Length > 0
&& !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
{
var siteRepo = sp.GetRequiredService();
var permitted = new HashSet(user.PermittedSiteIds);
permittedIdentifiers = (await siteRepo.GetAllSitesAsync())
.Where(s => permitted.Contains(s.Id.ToString()))
.Select(s => s.SiteIdentifier)
.ToList();
}
var repo = sp.GetRequiredService();
// Offset paging (arch-review P3): clamp untrusted paging inputs so a crafted
// command can neither request a negative offset nor an unbounded page.
var take = Math.Clamp(cmd.Take, 1, 500);
var skip = Math.Max(0, cmd.Skip);
var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip, take, permittedIdentifiers);
var totalCount = await repo.CountAsync(cmd.Status, cmd.SiteId, permittedIdentifiers);
// Opportunistic expiry sweep (arch-review S2): every time the list is read, walk
// the page and expire any overdue Pending row. TryExpireIfStaleAsync self-filters
// by age (fresh rows are no-ops) and CAS-guards the transition, so this is
// deliberately lazy/opportunistic — the ManagementActor already runs on every
// central node, so a dedicated expiry-timer singleton would be redundant, and the
// compare-and-swap makes two nodes sweeping the same overdue row concurrently
// harmless (exactly one wins; the loser observes false and re-projects the row's
// freshly-Expired state). The mutation lands on the in-memory row before projection,
// so the caller sees Expired in the same response.
foreach (var row in rows)
{
if (string.Equals(row.Status, "Pending", StringComparison.Ordinal))
await TryExpireIfStaleAsync(sp, repo, row);
}
return new SecuredWriteListResult(rows.Select(ToSecuredWriteDto).ToList(), totalCount);
}
// ========================================================================
// Site handlers
// ========================================================================
private static async Task HandleListSites(IServiceProvider sp, AuthenticatedUser user)
{
var repo = sp.GetRequiredService();
var sites = await repo.GetAllSitesAsync();
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
{
var permittedIds = new HashSet(user.PermittedSiteIds);
sites = sites.Where(s => permittedIds.Contains(s.Id.ToString())).ToList();
}
return sites;
}
///
/// Reads one site, scope-checked against itself. Fail-CLOSED on an unresolvable
/// id for a scoped caller — see
/// (register row 98).
///
private static async Task HandleGetSite(IServiceProvider sp, GetSiteCommand cmd, AuthenticatedUser user)
{
var repo = sp.GetRequiredService();
var site = await repo.GetSiteByIdAsync(cmd.SiteId);
if (site is null)
{
DenyUnresolvedTargetIfScoped(user, $"site {cmd.SiteId}");
return null;
}
EnforceSiteScope(user, site.Id);
return site;
}
private static async Task HandleCreateSite(IServiceProvider sp, CreateSiteCommand cmd, string user)
{
var repo = sp.GetRequiredService();
var site = new Site(cmd.Name, cmd.SiteIdentifier)
{
Description = cmd.Description,
NodeAAddress = cmd.NodeAAddress,
NodeBAddress = cmd.NodeBAddress,
GrpcNodeAAddress = cmd.GrpcNodeAAddress,
GrpcNodeBAddress = cmd.GrpcNodeBAddress
};
await repo.AddSiteAsync(site);
await repo.SaveChangesAsync();
var commService = sp.GetService();
await AfterConfigurationCommit(sp, () => { commService?.RefreshSiteAddresses(); return Task.CompletedTask; });
await AuditAsync(sp, user, "Create", "Site", site.Id.ToString(), site.Name, site);
return site;
}
private static async Task HandleUpdateSite(IServiceProvider sp, UpdateSiteCommand cmd, string user)
{
var repo = sp.GetRequiredService();
var site = await repo.GetSiteByIdAsync(cmd.SiteId)
?? throw new ManagementCommandException($"Site with ID {cmd.SiteId} not found.");
site.Name = cmd.Name;
site.Description = cmd.Description;
site.NodeAAddress = cmd.NodeAAddress;
site.NodeBAddress = cmd.NodeBAddress;
site.GrpcNodeAAddress = cmd.GrpcNodeAAddress;
site.GrpcNodeBAddress = cmd.GrpcNodeBAddress;
await repo.UpdateSiteAsync(site);
await repo.SaveChangesAsync();
var commService = sp.GetService();
await AfterConfigurationCommit(sp, () => { commService?.RefreshSiteAddresses(); return Task.CompletedTask; });
await AuditAsync(sp, user, "Update", "Site", site.Id.ToString(), site.Name, site);
return site;
}
private static async Task HandleDeleteSite(IServiceProvider sp, DeleteSiteCommand cmd, string user)
{
var repo = sp.GetRequiredService();
var site = await repo.GetSiteByIdAsync(cmd.SiteId);
var instances = await repo.GetInstancesBySiteIdAsync(cmd.SiteId);
if (instances.Count > 0)
throw new ManagementCommandException(
$"Cannot delete site {cmd.SiteId}: it has {instances.Count} instance(s).");
await repo.DeleteSiteAsync(cmd.SiteId);
await repo.SaveChangesAsync();
var commService = sp.GetService();
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
// production caller (arch review 02 round 2, N8): a deleted site's channels
// (both node endpoints) otherwise persist until process restart. Null-safe:
// test/composition roots without the factory skip it. Any live-alarm
// aggregator for the site reconciles to an empty snapshot and is reaped by
// the viewer linger (documented acceptance — see Component-Communication.md).
var grpcFactory = sp.GetService();
if (grpcFactory is not null && site is not null)
await AfterConfigurationCommit(sp, () => grpcFactory.RemoveSiteAsync(site.SiteIdentifier));
await AuditAsync(sp, user, "Delete", "Site", cmd.SiteId.ToString(), site?.Name ?? cmd.SiteId.ToString(), null);
return true;
}
private static async Task HandleListAreas(IServiceProvider sp, ListAreasCommand cmd, AuthenticatedUser user)
{
EnforceSiteScope(user, cmd.SiteId);
var repo = sp.GetRequiredService();
return await repo.GetAreaTreeBySiteIdAsync(cmd.SiteId);
}
private static async Task HandleCreateArea(IServiceProvider sp, CreateAreaCommand cmd, AuthenticatedUser user)
{
EnforceSiteScope(user, cmd.SiteId);
var result = await sp.GetRequiredService().CreateAreaAsync(cmd.Name, cmd.SiteId, cmd.ParentAreaId, user.Username);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task HandleDeleteArea(IServiceProvider sp, DeleteAreaCommand cmd, AuthenticatedUser user)
{
var area = await sp.GetRequiredService().GetAreaByIdAsync(cmd.AreaId)
?? throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found.");
EnforceSiteScope(user, area.SiteId);
var result = await sp.GetRequiredService().DeleteAreaAsync(cmd.AreaId, user.Username);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
// ========================================================================
// Data Connection handlers
// ========================================================================
///
/// Secret-elided projection of a DataConnection (arch-review C3): the raw
/// PrimaryConfiguration/BackupConfiguration JSON can embed OPC UA user and
/// certificate passwords, and List/Get are readable by any authenticated user.
/// Mirrors . The secret-bearing config blobs
/// are scrubbed to the redaction sentinel; every non-config field is projected
/// verbatim so callers keep the full shape they had before.
///
private static object DataConnectionPublicShape(DataConnection c)
{
var (primary, s1) = ConfigSecretScrubber.Scrub(c.PrimaryConfiguration);
var (backup, s2) = ConfigSecretScrubber.Scrub(c.BackupConfiguration);
return new
{
c.Id,
c.SiteId,
c.Name,
c.Protocol,
c.FailoverRetryCount,
PrimaryConfiguration = primary,
BackupConfiguration = backup,
HasSecrets = s1 || s2,
};
}
///
/// Lists data connections, constrained to the caller's permitted sites.
///
///
/// Arch-review 2026-08-19 P6-4. The list used to take no user at all and return
/// EVERY site's connections, which nullified the row-98 fail-closed gate on
/// — Get denied exactly what List handed
/// over, and the secret-elided still
/// leaked site topology plus an id-existence oracle. Scoping now mirrors
/// : an explicit site filter is scope-checked like
/// any other site-keyed command, and an unfiltered list from a site-scoped
/// non-admin caller is narrowed to their permitted sites. System-wide callers
/// (Administrator, or a Deployer with no site grants) are unaffected.
///
private static async Task HandleListDataConnections(
IServiceProvider sp, ListDataConnectionsCommand cmd, AuthenticatedUser user)
{
var repo = sp.GetRequiredService();
if (cmd.SiteId.HasValue)
{
EnforceSiteScope(user, cmd.SiteId.Value);
return (await repo.GetDataConnectionsBySiteIdAsync(cmd.SiteId.Value)).Select(DataConnectionPublicShape).ToList();
}
IEnumerable connections = await repo.GetAllDataConnectionsAsync();
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
{
var permittedIds = new HashSet(user.PermittedSiteIds);
connections = connections.Where(c => permittedIds.Contains(c.SiteId.ToString()));
}
return connections.Select(DataConnectionPublicShape).ToList();
}
///
/// Reads one data connection, scope-checked against its owning site. Fail-CLOSED
/// on an unresolvable id for a scoped caller — see
/// (register row 98).
///
private static async Task HandleGetDataConnection(IServiceProvider sp, GetDataConnectionCommand cmd, AuthenticatedUser user)
{
var repo = sp.GetRequiredService();
var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId);
if (conn is null)
{
DenyUnresolvedTargetIfScoped(user, $"data connection {cmd.DataConnectionId}");
return null;
}
EnforceSiteScope(user, conn.SiteId);
return DataConnectionPublicShape(conn);
}
private static async Task HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user)
{
var repo = sp.GetRequiredService();
var conn = new DataConnection(cmd.Name, cmd.Protocol, cmd.SiteId)
{
PrimaryConfiguration = cmd.PrimaryConfiguration,
BackupConfiguration = cmd.BackupConfiguration,
FailoverRetryCount = cmd.FailoverRetryCount
};
await repo.AddDataConnectionAsync(conn);
await repo.SaveChangesAsync();
var publicShape = DataConnectionPublicShape(conn);
await AuditAsync(sp, user, "Create", "DataConnection", conn.Id.ToString(), conn.Name, publicShape);
return publicShape;
}
private static async Task HandleUpdateDataConnection(IServiceProvider sp, UpdateDataConnectionCommand cmd, string user)
{
var repo = sp.GetRequiredService();
var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId)
?? throw new ManagementCommandException($"DataConnection with ID {cmd.DataConnectionId} not found.");
conn.Name = cmd.Name;
conn.Protocol = cmd.Protocol;
// Sentinel-preserving merge: a client that round-trips a scrubbed config
// (secrets replaced with the redaction sentinel) keeps the stored secret
// rather than wiping it. Non-sentinel values overwrite as usual.
conn.PrimaryConfiguration = ConfigSecretScrubber.MergeSentinels(cmd.PrimaryConfiguration, conn.PrimaryConfiguration);
conn.BackupConfiguration = ConfigSecretScrubber.MergeSentinels(cmd.BackupConfiguration, conn.BackupConfiguration);
conn.FailoverRetryCount = cmd.FailoverRetryCount;
await repo.UpdateDataConnectionAsync(conn);
await repo.SaveChangesAsync();
var publicShape = DataConnectionPublicShape(conn);
await AuditAsync(sp, user, "Update", "DataConnection", conn.Id.ToString(), conn.Name, publicShape);
return publicShape;
}
private static async Task HandleDeleteDataConnection(IServiceProvider sp, DeleteDataConnectionCommand cmd, string user)
{
var repo = sp.GetRequiredService();
await repo.DeleteDataConnectionAsync(cmd.DataConnectionId);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Delete", "DataConnection", cmd.DataConnectionId.ToString(), cmd.DataConnectionId.ToString(), null);
return true;
}
///
/// Moves a data connection to another site. High-risk + data
/// integrity: every guard runs server-side BEFORE the write, and when in
/// doubt the move is BLOCKED with a clear error rather than risking an
/// orphaned binding/reference. No bindings or name references are rewritten.
///
private static async Task HandleMoveDataConnection(IServiceProvider sp, MoveDataConnectionCommand cmd, string user)
{
var repo = sp.GetRequiredService();
var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId)
?? throw new ManagementCommandException($"DataConnection with ID {cmd.DataConnectionId} not found.");
var sourceSiteId = conn.SiteId;
if (sourceSiteId == cmd.TargetSiteId)
throw new ManagementCommandException(
$"DataConnection '{conn.Name}' (ID {conn.Id}) already belongs to site {cmd.TargetSiteId}.");
// Guard 1: target site must exist.
var targetSite = await repo.GetSiteByIdAsync(cmd.TargetSiteId)
?? throw new ManagementCommandException($"Target site with ID {cmd.TargetSiteId} not found.");
// Guard 2: no name collision at the target site.
var targetConnections = await repo.GetDataConnectionsBySiteIdAsync(cmd.TargetSiteId);
if (targetConnections.Any(c => c.Id != conn.Id
&& string.Equals(c.Name, conn.Name, StringComparison.OrdinalIgnoreCase)))
{
throw new ManagementCommandException(
$"Target site '{targetSite.Name}' (ID {cmd.TargetSiteId}) already has a data connection named '{conn.Name}'. " +
"Rename one of them before moving.");
}
// Guard 3 (primary data-integrity guard): no InstanceConnectionBinding may
// reference the connection. Instances are site-scoped, so a bound
// connection cannot leave its site without orphaning the binding.
var blockingInstances = await repo.GetInstancesReferencingDataConnectionAsync(conn.Id);
if (blockingInstances.Count > 0)
{
var names = string.Join(", ", blockingInstances
.OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
.Select(i => $"'{i.UniqueName}' (ID {i.Id})"));
throw new ManagementCommandException(
$"Cannot move data connection '{conn.Name}' (ID {conn.Id}): it is referenced by " +
$"{blockingInstances.Count} instance binding(s) on its current site. Rebind or delete the " +
$"following instance(s) first: {names}.");
}
// Guard 4 (name-based native-alarm-source references): templates reference
// a connection by Name (TemplateNativeAlarmSource.ConnectionName) and
// instances may override that name
// (InstanceNativeAlarmSourceOverride.ConnectionNameOverride). Moving a
// connection changes which physical connection that name resolves to on
// the SOURCE site, so any such reference *on the source site* to the
// moving name is a blocker. Detect and report only — never auto-rewrite.
//
// Scoping: templates are site-agnostic and ConnectionName resolves
// against the DEPLOYING site's connection pool at flatten time, so the
// template scan is restricted to templates actually instantiated on the
// SOURCE site. A template referencing the same connection NAME but only
// instantiated on another site is NOT affected by this move (that site
// keeps its own connection of the same name), so a global template scan
// would be a false positive that over-blocks legitimate moves whenever
// connection names are reused across sites (the common case).
//
// Comparison is StringComparison.Ordinal to match the
// flattening/deployment pipeline (FlatteningService / FlatteningPipeline
// resolve connection names case-sensitively) — block exactly the
// references the runtime would actually fail to resolve.
var referenceBlockers = new List();
// Source-site instances with their native-alarm-source overrides eagerly
// loaded (purpose-built load so the hot-path GetInstancesBySiteIdAsync
// stays lean). Reused below for both the override scan and to derive the
// set of templates to scan.
var sourceInstances = await repo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(sourceSiteId);
// Only templates instantiated on the SOURCE site can be broken by the move.
var templateRepo = sp.GetRequiredService();
var sourceTemplateIds = sourceInstances.Select(i => i.TemplateId).Distinct();
foreach (var templateId in sourceTemplateIds)
{
var template = await templateRepo.GetTemplateByIdAsync(templateId);
if (template is null) continue;
foreach (var source in template.NativeAlarmSources)
{
if (string.Equals(source.ConnectionName, conn.Name, StringComparison.Ordinal))
{
referenceBlockers.Add(
$"template '{template.Name}' native-alarm-source '{source.Name}'");
}
}
}
// Instance-level overrides on the SOURCE site that name this connection
// would orphan once the connection leaves the site.
foreach (var instance in sourceInstances)
{
foreach (var ovr in instance.NativeAlarmSourceOverrides)
{
if (string.Equals(ovr.ConnectionNameOverride, conn.Name, StringComparison.Ordinal))
{
referenceBlockers.Add(
$"instance '{instance.UniqueName}' (ID {instance.Id}) native-alarm-source override '{ovr.SourceCanonicalName}'");
}
}
}
if (referenceBlockers.Count > 0)
{
var details = string.Join(", ", referenceBlockers);
throw new ManagementCommandException(
$"Cannot move data connection '{conn.Name}' (ID {conn.Id}): its name is referenced by " +
$"native-alarm-source binding(s) that would be orphaned or made ambiguous by the move. " +
$"Resolve the following reference(s) first: {details}.");
}
// All guards passed: persist the site change and audit.
conn.SiteId = cmd.TargetSiteId;
await repo.UpdateDataConnectionAsync(conn);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Move", "DataConnection", conn.Id.ToString(), conn.Name, conn);
return conn;
}
// ========================================================================
// External System handlers
// ========================================================================
///
/// Secret-elided ExternalSystemDefinition projection (arch-review C3): the
/// opaque AuthConfiguration blob holds API keys / Basic credentials and
/// must never leave this boundary via responses or audit afterState. Presence
/// is surfaced as HasAuthConfiguration ; every non-secret field is
/// projected verbatim. Mirrors .
///
private static object ExternalSystemPublicShape(ExternalSystemDefinition d) => new
{
d.Id,
d.Name,
d.EndpointUrl,
d.AuthType,
d.MaxRetries,
d.RetryDelay,
d.TimeoutSeconds,
HasAuthConfiguration = !string.IsNullOrEmpty(d.AuthConfiguration),
};
private static async Task HandleListExternalSystems(IServiceProvider sp)
{
var repo = sp.GetRequiredService();
return (await repo.GetAllExternalSystemsAsync()).Select(ExternalSystemPublicShape).ToList();
}
private static async Task HandleGetExternalSystem(IServiceProvider sp, GetExternalSystemCommand cmd)
{
var repo = sp.GetRequiredService();
var def = await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId);
return def is null ? null : ExternalSystemPublicShape(def);
}
private static async Task HandleCreateExternalSystem(IServiceProvider sp, CreateExternalSystemCommand cmd, string user)
{
var repo = sp.GetRequiredService();
var def = new ExternalSystemDefinition(cmd.Name, cmd.EndpointUrl, cmd.AuthType)
{
AuthConfiguration = cmd.AuthConfiguration,
TimeoutSeconds = cmd.TimeoutSeconds ?? 0,
// 0 / TimeSpan.Zero is the entity's "unset" value — the site gateway maps it to the
// Store-and-Forward engine defaults rather than to a literal zero budget.
MaxRetries = cmd.MaxRetries ?? 0,
RetryDelay = cmd.RetryDelay ?? TimeSpan.Zero
};
await repo.AddExternalSystemAsync(def);
await repo.SaveChangesAsync();
var publicShape = ExternalSystemPublicShape(def);
await AuditAsync(sp, user, "Create", "ExternalSystem", def.Id.ToString(), def.Name, publicShape);
return publicShape;
}
private static async Task HandleUpdateExternalSystem(IServiceProvider sp, UpdateExternalSystemCommand cmd, string user)
{
var repo = sp.GetRequiredService();
var def = await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId)
?? throw new ManagementCommandException($"ExternalSystem with ID {cmd.ExternalSystemId} not found.");
def.Name = cmd.Name;
def.EndpointUrl = cmd.EndpointUrl;
def.AuthType = cmd.AuthType;
// Preserve-if-null: an update that omits AuthConfiguration leaves the stored
// secret intact (mirrors the SMTP/SMS credential preserve-if-null rule).
if (cmd.AuthConfiguration is not null) def.AuthConfiguration = cmd.AuthConfiguration;
if (cmd.TimeoutSeconds is { } ts) def.TimeoutSeconds = ts;
// Same preserve-if-null rule for the Store-and-Forward retry policy.
if (cmd.MaxRetries is { } mr) def.MaxRetries = mr;
if (cmd.RetryDelay is { } rd) def.RetryDelay = rd;
await repo.UpdateExternalSystemAsync(def);
await repo.SaveChangesAsync();
var publicShape = ExternalSystemPublicShape(def);
await AuditAsync(sp, user, "Update", "ExternalSystem", def.Id.ToString(), def.Name, publicShape);
return publicShape;
}
private static async Task HandleDeleteExternalSystem(IServiceProvider sp, DeleteExternalSystemCommand cmd, string user)
{
var repo = sp.GetRequiredService();
await repo.DeleteExternalSystemAsync(cmd.ExternalSystemId);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Delete", "ExternalSystem", cmd.ExternalSystemId.ToString(), cmd.ExternalSystemId.ToString(), null);
return true;
}
private static async Task HandleListExternalSystemMethods(IServiceProvider sp, ListExternalSystemMethodsCommand cmd)
{
var repo = sp.GetRequiredService();
return await repo.GetMethodsByExternalSystemIdAsync(cmd.ExternalSystemId);
}
private static async Task HandleGetExternalSystemMethod(IServiceProvider sp, GetExternalSystemMethodCommand cmd)
{
var repo = sp.GetRequiredService();
return await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId);
}
private static async Task HandleCreateExternalSystemMethod(IServiceProvider sp, CreateExternalSystemMethodCommand cmd, string user)
{
var repo = sp.GetRequiredService();
var method = new ExternalSystemMethod(cmd.Name, cmd.HttpMethod, cmd.Path)
{
ExternalSystemDefinitionId = cmd.ExternalSystemId,
ParameterDefinitions = cmd.ParameterDefinitions,
ReturnDefinition = cmd.ReturnDefinition
};
await repo.AddExternalSystemMethodAsync(method);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "ExternalSystemMethod", method.Id.ToString(), method.Name, method);
return method;
}
private static async Task HandleUpdateExternalSystemMethod(IServiceProvider sp, UpdateExternalSystemMethodCommand cmd, string user)
{
var repo = sp.GetRequiredService();
var method = await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId)
?? throw new ManagementCommandException($"ExternalSystemMethod with ID {cmd.MethodId} not found.");
if (cmd.Name != null) method.Name = cmd.Name;
if (cmd.HttpMethod != null) method.HttpMethod = cmd.HttpMethod;
if (cmd.Path != null) method.Path = cmd.Path;
if (cmd.ParameterDefinitions != null) method.ParameterDefinitions = cmd.ParameterDefinitions;
if (cmd.ReturnDefinition != null) method.ReturnDefinition = cmd.ReturnDefinition;
await repo.UpdateExternalSystemMethodAsync(method);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "ExternalSystemMethod", method.Id.ToString(), method.Name, method);
return method;
}
private static async Task HandleDeleteExternalSystemMethod(IServiceProvider sp, DeleteExternalSystemMethodCommand cmd, string user)
{
var repo = sp.GetRequiredService();
await repo.DeleteExternalSystemMethodAsync(cmd.MethodId);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Delete", "ExternalSystemMethod", cmd.MethodId.ToString(), cmd.MethodId.ToString(), null);
return true;
}
// ========================================================================
// Notification handlers
// ========================================================================
private static async Task HandleRenameNotificationList(IServiceProvider sp, RenameNotificationListCommand cmd, string user)
{
var repo = sp.GetRequiredService();
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 HandleAddNotificationRecipient(IServiceProvider sp, AddNotificationRecipientCommand cmd, string user)
{
var repo = sp.GetRequiredService();
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 HandleDeleteNotificationRecipient(IServiceProvider sp, DeleteNotificationRecipientCommand cmd, string user)
{
var repo = sp.GetRequiredService();
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 HandleDeleteAttributeOverride(IServiceProvider sp, DeleteInstanceAttributeOverrideCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var repo = sp.GetRequiredService();
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 HandleListNotificationLists(IServiceProvider sp)
{
var repo = sp.GetRequiredService();
return await repo.GetAllNotificationListsAsync();
}
private static async Task HandleGetNotificationList(IServiceProvider sp, GetNotificationListCommand cmd)
{
var repo = sp.GetRequiredService();
return await repo.GetNotificationListByIdAsync(cmd.NotificationListId);
}
private static async Task HandleCreateNotificationList(IServiceProvider sp, CreateNotificationListCommand cmd, string user)
{
var repo = sp.GetRequiredService();
var list = new NotificationList(cmd.Name) { Type = cmd.Type };
foreach (var recipient in BuildRecipients(cmd.Type, cmd.RecipientEmails, cmd.RecipientPhones))
{
list.Recipients.Add(recipient);
}
await repo.AddNotificationListAsync(list);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "NotificationList", list.Id.ToString(), list.Name, list);
return list;
}
private static async Task HandleUpdateNotificationList(IServiceProvider sp, UpdateNotificationListCommand cmd, string user)
{
var repo = sp.GetRequiredService();
var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId)
?? throw new ManagementCommandException($"NotificationList with ID {cmd.NotificationListId} not found.");
if (list.Type != cmd.Type)
throw new ManagementCommandException("A notification list's Type cannot be changed after creation.");
list.Name = cmd.Name;
var existingRecipients = await repo.GetRecipientsByListIdAsync(cmd.NotificationListId);
foreach (var r in existingRecipients)
{
await repo.DeleteRecipientAsync(r.Id);
}
foreach (var recipient in BuildRecipients(cmd.Type, cmd.RecipientEmails, cmd.RecipientPhones))
{
recipient.NotificationListId = cmd.NotificationListId;
await repo.AddRecipientAsync(recipient);
}
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 HandleDeleteNotificationList(IServiceProvider sp, DeleteNotificationListCommand cmd, string user)
{
var repo = sp.GetRequiredService();
await repo.DeleteNotificationListAsync(cmd.NotificationListId);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Delete", "NotificationList", cmd.NotificationListId.ToString(), cmd.NotificationListId.ToString(), null);
return true;
}
///
/// SMS Notifications: build the recipient set for a notification list according
/// to its delivery channel. Email lists map each
/// entry to an recipient; SMS lists map
/// each entry to an
/// recipient. The off-channel source list is ignored so an Email list never stores a
/// phone in EmailAddress (and vice versa).
///
private static IEnumerable BuildRecipients(
NotificationType type, IReadOnlyList recipientEmails, IReadOnlyList? recipientPhones)
{
if (type == NotificationType.Sms)
{
foreach (var phone in recipientPhones ?? Array.Empty())
{
yield return NotificationRecipient.ForSms(phone, phone);
}
}
else
{
foreach (var email in recipientEmails)
{
yield return NotificationRecipient.ForEmail(email, email);
}
}
}
///
/// Project an SmtpConfiguration to a credential-free shape so the
/// stored Credentials (SMTP password / OAuth2 client secret) never leaves this
/// boundary via response payloads or audit afterState. Mirrors the
/// ApiKey-projection pattern in HandleListApiKeys.
///
private static object SmtpConfigPublicShape(Commons.Entities.Notifications.SmtpConfiguration c) =>
new
{
c.Id,
c.Host,
c.Port,
c.AuthType,
c.FromAddress,
c.TlsMode,
c.Transport,
c.ConnectionTimeoutSeconds,
c.MaxConcurrentConnections,
c.MaxRetries,
c.RetryDelay,
c.OAuth2Authority,
c.OAuth2Scope,
HasCredentials = !string.IsNullOrEmpty(c.Credentials),
};
private static async Task HandleListSmtpConfigs(IServiceProvider sp)
{
var repo = sp.GetRequiredService();
var configs = await repo.GetAllSmtpConfigurationsAsync();
// Project away the Credentials field — read access to this
// list is broader than the Admin-only UpdateSmtpConfig path that owns
// the secret.
return configs.Select(SmtpConfigPublicShape).ToList();
}
private static async Task HandleUpdateSmtpConfig(IServiceProvider sp, UpdateSmtpConfigCommand cmd, string user)
{
var repo = sp.GetRequiredService();
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;
config.AuthType = cmd.AuthMode;
config.FromAddress = cmd.FromAddress;
// Preserve-if-null: an update that omits TlsMode/Credentials leaves the
// existing values intact (non-breaking for callers that do not send them).
if (cmd.TlsMode is not null) config.TlsMode = cmd.TlsMode;
if (cmd.Credentials is not null) config.Credentials = cmd.Credentials;
// Preserve-if-null likewise for the OAuth2 token-endpoint overrides: an
// omitted authority/scope leaves the stored value (or its M365 default) intact.
if (cmd.OAuth2Authority is not null) config.OAuth2Authority = cmd.OAuth2Authority;
if (cmd.OAuth2Scope is not null) config.OAuth2Scope = cmd.OAuth2Scope;
// Preserve-if-null for the transport too: an omitted value keeps the stored
// one (null on every pre-EWS row, which means SMTP).
if (cmd.Transport is not null) config.Transport = cmd.Transport;
ValidateSmtpConfigTransportShape(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, cmd is CreateSmtpConfigCommand ? "Create" : "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, publicShape);
return publicShape;
}
///
/// Write-path gate for the email transport: validates the EFFECTIVE
/// post-assignment state of an SmtpConfiguration so an operator cannot persist a
/// row the delivery adapter would immediately park every notification on. The
/// adapter re-validates at delivery time and remains the authoritative check —
/// this is a fail-fast at the point of the mistake, not a second source of truth.
///
/// The configuration with all command assignments already applied.
/// The transport is unknown, or the EWS shape rules are violated.
private static void ValidateSmtpConfigTransportShape(Commons.Entities.Notifications.SmtpConfiguration config)
{
// Mirrors NotificationService's EmailTransportParser, inlined because
// ManagementService does not (and should not) reference NotificationService
// just for a two-value parse. Keep the accepted values in step with it.
var isEws = false;
var transport = config.Transport;
if (!string.IsNullOrWhiteSpace(transport))
{
switch (transport.Trim().ToLowerInvariant())
{
case "smtp":
break;
case "ews":
isEws = true;
break;
default:
throw new ManagementCommandException(
$"Unknown email transport '{transport}'. Expected one of: Smtp, Ews.");
}
}
if (!isEws)
{
return;
}
// EWS reuses Host as the SOAP endpoint URL, not a mail-server hostname.
if (!Uri.TryCreate(config.Host, UriKind.Absolute, out var endpoint)
|| !string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal))
{
throw new ManagementCommandException(
"EWS transport requires Host to be an absolute https:// EWS endpoint URL "
+ $"(e.g. https://mail.example.com/ews/exchange.asmx); got '{config.Host}'.");
}
// The EWS sender authenticates with HTTP Basic only — no OAuth2 flow exists
// on that path, so an OAuth2 row would park on every send.
if (!string.Equals(config.AuthType, "basic", StringComparison.OrdinalIgnoreCase))
{
throw new ManagementCommandException(
"EWS transport supports only Basic authentication; set AuthMode to 'basic'.");
}
// Same split semantics as the delivery adapter: FIRST colon only, because a
// domain-qualified user name contains none but a password legitimately may.
// Without this the common mistake — flipping an existing SMTP row (whose
// Credentials is a bare password) to Ews — would persist happily and then park
// every single notification at delivery time.
var credentialParts = config.Credentials?.Split(':', 2) ?? [];
if (credentialParts.Length != 2 || credentialParts[0].Length == 0 || credentialParts[1].Length == 0)
{
throw new ManagementCommandException(
"EWS transport requires Credentials in 'username:password' form "
+ "(username may be 'domain\\user').");
}
}
///
/// Project an SmsConfiguration to a credential-free shape so the
/// stored AuthToken (Twilio Auth Token secret) never leaves this boundary via
/// response payloads or audit afterState. Mirrors .
///
private static object SmsConfigPublicShape(Commons.Entities.Notifications.SmsConfiguration c) =>
new
{
c.Id,
c.AccountSid,
c.FromNumber,
c.MessagingServiceSid,
c.ApiBaseUrl,
c.ConnectionTimeoutSeconds,
c.MaxRetries,
c.RetryDelay,
HasAuthToken = !string.IsNullOrEmpty(c.AuthToken),
};
private static async Task HandleListSmsConfigs(IServiceProvider sp)
{
var repo = sp.GetRequiredService();
var configs = await repo.GetAllSmsConfigurationsAsync();
// Project away the AuthToken field — read access to this
// list is broader than the Admin-only UpdateSmsConfig path that owns
// the secret.
return configs.Select(SmsConfigPublicShape).ToList();
}
private static async Task HandleUpdateSmsConfig(IServiceProvider sp, UpdateSmsConfigCommand cmd, string user)
{
var repo = sp.GetRequiredService();
var configs = await repo.GetAllSmsConfigurationsAsync();
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
// Twilio SID at save time, closing the URI-injection door before it is persisted.
if (!System.Text.RegularExpressions.Regex.IsMatch(cmd.AccountSid, "^AC[0-9a-fA-F]{32}$"))
throw new ManagementCommandException("Account SID must match Twilio's format: 'AC' followed by 32 hex characters.");
config.AccountSid = cmd.AccountSid;
config.FromNumber = cmd.FromNumber;
config.MessagingServiceSid = cmd.MessagingServiceSid;
// Preserve-if-null: an update that omits ApiBaseUrl/AuthToken leaves the
// existing values intact (non-breaking for callers that do not send them,
// and so the secret AuthToken survives a config edit that does not rotate it).
if (cmd.ApiBaseUrl is not null) config.ApiBaseUrl = cmd.ApiBaseUrl;
// Treat an empty/whitespace AuthToken as "omitted", not "clear".
// A Twilio Auth Token is always required, so an empty value is never a valid
// rotation — guarding against IsNullOrEmpty (not just null) stops an accidental
// `--auth-token ""` from silently wiping the stored secret and 401-ing every
// 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;
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, cmd is CreateSmsConfigCommand ? "Create" : "Update", "SmsConfiguration", config.Id.ToString(), config.AccountSid, publicShape);
return publicShape;
}
// ========================================================================
// Security handlers
// ========================================================================
private static async Task HandleListRoleMappings(IServiceProvider sp)
{
var repo = sp.GetRequiredService();
return await repo.GetAllMappingsAsync();
}
// An LDAP-group mapping's Role is a free string on the wire (CLI/API), so both
// validate membership AND canonicalize casing at the single server-side write path.
// The stored Role must be CANONICAL casing (Roles.All): ASP.NET RequireClaim policies
// compare ordinal case-sensitively while the actor compares OrdinalIgnoreCase, so a
// row stored as "deployer" passes every actor gate but fails every UI policy — a
// CLI-works/UI-403s split-brain. Canonicalizing here closes the asymmetry (arch-review
// C5; closes the deferral formerly documented here). A role outside Roles.All never
// functioned (no policy or authz check matches it), so rejecting it removes a
// silent-misconfiguration footgun rather than changing behaviour.
private static string CanonicalizeMappingRole(string role)
{
var canonical = Roles.All.FirstOrDefault(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
return canonical ?? throw new ManagementCommandException(
$"Role '{role}' is not a recognized role. Valid roles are: {string.Join(", ", Roles.All)}.");
}
private static async Task HandleCreateRoleMapping(IServiceProvider sp, CreateRoleMappingCommand cmd, string user)
{
var role = CanonicalizeMappingRole(cmd.Role);
var repo = sp.GetRequiredService();
var mapping = new LdapGroupMapping(cmd.LdapGroupName, role);
await repo.AddMappingAsync(mapping);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "RoleMapping", mapping.Id.ToString(), $"{mapping.LdapGroupName}->{mapping.Role}", mapping);
return mapping;
}
private static async Task HandleUpdateRoleMapping(IServiceProvider sp, UpdateRoleMappingCommand cmd, string user)
{
var role = CanonicalizeMappingRole(cmd.Role);
var repo = sp.GetRequiredService();
var mapping = await repo.GetMappingByIdAsync(cmd.MappingId)
?? throw new ManagementCommandException($"RoleMapping with ID {cmd.MappingId} not found.");
mapping.LdapGroupName = cmd.LdapGroupName;
mapping.Role = role;
await repo.UpdateMappingAsync(mapping);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "RoleMapping", mapping.Id.ToString(), $"{mapping.LdapGroupName}->{mapping.Role}", mapping);
return mapping;
}
private static async Task HandleDeleteRoleMapping(IServiceProvider sp, DeleteRoleMappingCommand cmd, string user)
{
var repo = sp.GetRequiredService();
await repo.DeleteMappingAsync(cmd.MappingId);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Delete", "RoleMapping", cmd.MappingId.ToString(), cmd.MappingId.ToString(), null);
return true;
}
private static async Task HandleListApiKeys(IServiceProvider sp)
{
// Inbound-API key re-arch: keys are now managed through the shared
// IInboundApiKeyAdmin seam (over the ZB.MOM.WW.Auth.ApiKeys library) rather than
// the SQL Server IInboundApiRepository. The seam projection is hash-free by
// construction — only identity, status, and method-scopes are returned; the
// secret is shown once at creation (token) and is never retrievable.
var admin = sp.GetRequiredService();
var keys = await admin.ListAsync();
return keys
.Select(k => new { k.KeyId, k.Name, k.Enabled, k.Methods })
.ToList();
}
private static async Task HandleCreateApiKey(IServiceProvider sp, CreateApiKeyCommand cmd, string user)
{
// Inbound-API key re-arch: the library mints the key, persists only the
// peppered hash, and assembles the one-time bearer token (sbk__).
// The token is shown to the operator only here, in the create response; it cannot
// be retrieved later. No hash/secret is stored or returned by ScadaBridge.
if (cmd.Methods is null || cmd.Methods.Count == 0)
throw new ManagementCommandException("At least one method must be specified for an API key.");
var admin = sp.GetRequiredService();
var created = await admin.CreateAsync(cmd.Name, cmd.Methods);
await AuditAsync(sp, user, "Create", "ApiKey", created.KeyId, cmd.Name,
new { created.KeyId, cmd.Name });
return new
{
created.KeyId,
cmd.Name,
created.Token,
};
}
private static async Task HandleDeleteApiKey(IServiceProvider sp, DeleteApiKeyCommand cmd, string user)
{
var admin = sp.GetRequiredService();
var deleted = await admin.DeleteAsync(cmd.KeyId);
if (!deleted)
throw new ManagementCommandException($"API key '{cmd.KeyId}' not found.");
await AuditAsync(sp, user, "Delete", "ApiKey", cmd.KeyId, cmd.KeyId, null);
return deleted;
}
// ========================================================================
// Deployment handlers
// ========================================================================
private static async Task HandleDeployArtifacts(IServiceProvider sp, MgmtDeployArtifactsCommand cmd, AuthenticatedUser user)
{
// A site-scoped Deployer may not trigger a FLEET-WIDE artifact deployment:
// EnforceSiteScope(null) is a no-op by design, so the fleet-wide case needs
// its own guard (arch-review C2 authorization gap).
if (cmd.SiteId is null
&& user.PermittedSiteIds.Length > 0
&& !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
{
throw new SiteScopeViolationException(
"Site-scoped users cannot deploy artifacts fleet-wide; specify a target site id.");
}
EnforceSiteScope(user, cmd.SiteId);
var svc = sp.GetRequiredService();
var result = cmd.SiteId is int siteId
? await svc.DeployToSiteAsync(siteId, user.Username)
: await svc.DeployToAllSitesAsync(user.Username);
return result.IsSuccess
? result.Value
: throw new ManagementCommandException(result.Error);
}
private static async Task HandleQueryDeployments(IServiceProvider sp, QueryDeploymentsCommand cmd, AuthenticatedUser user)
{
var repo = sp.GetRequiredService();
// Opportunistic retention sweep for terminal records. Rate-limited
// process-wide and best-effort inside the service, so it costs this query
// nothing on all but the first call in a retention interval — and it is
// deliberately on the LIST path, the operation that degrades when the
// insert-only DeploymentRecords table is allowed to grow unbounded.
//
// GetService, not GetRequiredService: retention maintenance is a
// side-benefit of this handler, not a precondition of answering the query.
// A composition root that wires the management surface without the
// deployment services (as the actor's own tests do) must still be able to
// list deployments.
if (sp.GetService() is { } deploymentService)
await deploymentService.TryPurgeTerminalDeploymentRecordsAsync();
// Page, PageSize and Status are all honoured DB-side, through the SAME
// paged read the Central UI deployment page uses (register row 37 — there
// used to be a second, subtly different repository query behind this
// handler alone).
var status = ParseDeploymentStatus(cmd.Status);
var page = Math.Max(1, cmd.Page);
var pageSize = ResolveDeploymentPageSize(cmd.PageSize);
// Instance-scoped query: enforce against the target instance's site
// before reading its deployment history.
if (cmd.InstanceId.HasValue)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId.Value);
return await QueryDeploymentPage(
repo, new DeploymentListFilter(status, InstanceId: cmd.InstanceId.Value), page, pageSize);
}
// Unfiltered query: a site-scoped Deployment user must only see records
// for instances at sites within their scope. DeploymentRecord has no
// SiteId of its own, so the scope rides as a SITE-ID filter resolved by the
// query's own join to Instance. It used to be resolved here instead, by
// loading EVERY instance in the system on every scoped query to turn the
// site grant into an instance-id list (register row 87).
var siteScope = ResolveSiteIdScope(user);
return await QueryDeploymentPage(
repo, new DeploymentListFilter(status, SiteIdScope: siteScope), page, pageSize);
}
///
/// Runs the shared paged deployment read and wraps it in the command's result
/// envelope, echoing the page coordinates that were actually applied.
///
private static async Task QueryDeploymentPage(
IDeploymentManagerRepository repo, DeploymentListFilter filter, int page, int pageSize)
{
var result = await repo.QueryDeploymentListPageAsync(filter, page, pageSize);
return new DeploymentQueryResult(result.Rows, result.TotalCount, page, pageSize);
}
///
/// Resolves the caller's site-scope grant into the query's
/// : for a
/// system-wide caller, otherwise the permitted site ids.
///
///
/// Administrator is system-wide here, matching
/// and the rest of this actor. A permitted-site id that is not an integer is
/// DROPPED rather than ignored: dropping can only narrow the grant, whereas
/// treating the whole grant as unparseable would widen it to fleet-wide. A
/// grant of nothing but garbage therefore yields an EMPTY scope, which the
/// filter treats as a real "sees nothing" constraint.
///
private static IReadOnlyCollection? ResolveSiteIdScope(AuthenticatedUser user)
{
if (user.PermittedSiteIds.Length == 0) return null;
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return null;
var ids = new List(user.PermittedSiteIds.Length);
foreach (var raw in user.PermittedSiteIds)
{
if (int.TryParse(raw, out var id))
ids.Add(id);
}
return ids;
}
///
/// Bounds the requested deployment page size: a non-positive value means
/// "unspecified" and takes the default, and every value is capped at
/// .
///
///
/// Register row 87. A non-positive size used to mean UNLIMITED all the way down
/// to the repository, so --page-size 0 materialized the entire
/// terminal-record retention window into one response. The result envelope
/// carries the total count precisely so bounding the page does not hide rows.
///
private static int ResolveDeploymentPageSize(int requested) =>
requested <= 0
? DeploymentListPage.DefaultPageSize
: Math.Min(requested, DeploymentListPage.MaxPageSize);
///
/// Parses the wire Status filter into a .
/// Absent/blank means "no filter"; an unrecognised value is an ERROR naming the
/// valid ones.
///
///
/// Register row 88. An unparseable value used to fall back to "no filter", so
/// --status Sucess silently returned every status — the caller saw a
/// plausible, complete-looking result that did not answer the question they
/// asked. Same loud-failure principle as row 46.
///
/// The value is not a deployment status.
private static DeploymentStatus? ParseDeploymentStatus(string? status)
{
if (string.IsNullOrWhiteSpace(status)) return null;
var trimmed = status.Trim();
// Reject ordinals before TryParse — "2" parses to InProgress, which the
// documented names-only contract does not admit. No enum member name can
// start with a digit or a sign (same guard as AuditQueryParamParsers).
var looksNumeric = char.IsAsciiDigit(trimmed[0]) || trimmed[0] == '+' || trimmed[0] == '-';
if (!looksNumeric
&& Enum.TryParse(trimmed, ignoreCase: true, out var parsed)
&& Enum.IsDefined(parsed))
{
return parsed;
}
throw new ManagementCommandException(
$"Unknown deployment status '{status}'. Valid values: {string.Join(", ", Enum.GetNames())}.");
}
// ========================================================================
// Audit Log handler
// ========================================================================
///
/// Queries the configuration-change audit trail and projects the repository's
/// ValueTuple into .
///
///
/// The projection is load-bearing, not cosmetic: returning the repository tuple
/// directly made emit the literal {} (ValueTuple
/// exposes Item1 /Item2 as fields, which System.Text.Json skips), so
/// audit-config query reported an empty result for every filter combination
/// regardless of how many rows matched — deferred-work register row 46.
///
private static async Task HandleQueryAuditLog(IServiceProvider sp, QueryAuditLogCommand cmd)
{
var repo = sp.GetRequiredService();
var (entries, totalCount) = await repo.GetAuditLogEntriesAsync(
cmd.User, cmd.EntityType, cmd.Action, cmd.From, cmd.To,
page: cmd.Page, pageSize: cmd.PageSize);
return new AuditLogQueryResult(entries, totalCount);
}
// ========================================================================
// Health handlers
// ========================================================================
private static object? HandleGetHealthSummary(IServiceProvider sp)
{
var aggregator = sp.GetRequiredService();
return aggregator.GetAllSiteStates();
}
private static object? HandleGetSiteHealth(IServiceProvider sp, GetSiteHealthCommand cmd)
{
var aggregator = sp.GetRequiredService();
return aggregator.GetSiteState(cmd.SiteIdentifier);
}
// ========================================================================
// Template member handlers
// ========================================================================
private static async Task HandleAddAttribute(IServiceProvider sp, AddTemplateAttributeCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var dataType = ParseDataType(cmd.DataType);
var elementType = ParseElementDataType(cmd.ElementDataType);
ValidateAttributeTypes(cmd.Name, dataType, elementType, cmd.Value);
var attr = new TemplateAttribute(cmd.Name)
{
DataType = dataType,
ElementDataType = elementType,
Value = cmd.Value,
Description = cmd.Description,
DataSourceReference = cmd.DataSourceReference,
IsLocked = cmd.IsLocked
};
var result = await svc.AddAttributeAsync(cmd.TemplateId, attr, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task HandleUpdateAttribute(IServiceProvider sp, UpdateTemplateAttributeCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var dataType = ParseDataType(cmd.DataType);
var elementType = ParseElementDataType(cmd.ElementDataType);
ValidateAttributeTypes(cmd.Name, dataType, elementType, cmd.Value);
var attr = new TemplateAttribute(cmd.Name)
{
DataType = dataType,
ElementDataType = elementType,
Value = cmd.Value,
Description = cmd.Description,
DataSourceReference = cmd.DataSourceReference,
IsLocked = cmd.IsLocked
};
var result = await svc.UpdateAttributeAsync(cmd.AttributeId, attr, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
///
/// Parses a required data type token. Throws
/// (surfaced to the caller as a curated COMMAND_FAILED message) on an
/// unrecognised type name, rather than letting Enum.Parse 's
/// be masked as a generic internal error.
///
private static Commons.Types.Enums.DataType ParseDataType(string? dataType)
=> ParseEnumStrict("data type", dataType);
///
/// Parses an optional element data type token. Returns null when the token is
/// empty/whitespace; throws on an
/// unrecognised type name.
///
private static Commons.Types.Enums.DataType? ParseElementDataType(string? elementDataType)
{
if (string.IsNullOrWhiteSpace(elementDataType)) return null;
return ParseEnumStrict("element type", elementDataType);
}
///
/// Parses a single required enum token by NAME ONLY, rejecting ordinals,
/// comma-joined name lists, and defined-but-out-of-range composites; throws a
/// curated naming the field and the
/// valid value set on any rejection.
///
///
/// Register row 88 pattern (see ). Plain
/// Enum.TryParse /Enum.Parse admits three traps a wire-contract
/// field must not tolerate:
///
/// Ordinals. "2" parses to whatever member sits at that
/// position (e.g. DataType.Float ) — a caller-facing field is a
/// name-based contract, never a positional one.
/// Undefined ordinals. "99" parses to an undefined enum
/// value that saves without error and only detonates at deploy time.
/// Comma-joined tokens. Enum.TryParse bitwise-ORs a
/// comma-separated string's pieces together regardless of whether the enum
/// is [Flags] — "String,Int32" (4|1) silently becomes
/// DateTime (5) for a dense 0-based enum. Any token containing a
/// comma is rejected outright, before TryParse ever sees it.
///
/// Mirrors ,
/// adapted to a single required value and .
///
/// The value is not a defined member name of .
private static TEnum ParseEnumStrict(string fieldName, string? raw)
where TEnum : struct, Enum
{
var trimmed = raw?.Trim() ?? string.Empty;
var looksNumeric = trimmed.Length > 0
&& (char.IsAsciiDigit(trimmed[0]) || trimmed[0] == '+' || trimmed[0] == '-');
if (!looksNumeric
&& !trimmed.Contains(',')
&& Enum.TryParse(trimmed, ignoreCase: true, out var parsed)
&& Enum.IsDefined(parsed))
{
return parsed;
}
throw new ManagementCommandException(
$"Unrecognised {fieldName} '{raw}'. Valid values: {string.Join(", ", Enum.GetNames())}.");
}
///
/// Validates the (DataType, ElementDataType, Value) triple shared by the add
/// and update attribute handlers. Throws
/// on any violation:
///
/// - List attributes require a valid scalar element type.
/// - Scalar attributes may not carry an element type.
/// - A List default value must decode against the declared element type.
///
///
private static void ValidateAttributeTypes(
string name, Commons.Types.Enums.DataType dataType, Commons.Types.Enums.DataType? elementType, string? value)
{
if (dataType == Commons.Types.Enums.DataType.List)
{
if (elementType is null || !Commons.Types.AttributeValueCodec.IsValidElementType(elementType.Value))
throw new ManagementCommandException(
$"List attribute '{name}' requires a valid element type (String, Int32, Float, Double, Boolean, DateTime).");
if (!string.IsNullOrWhiteSpace(value))
{
try
{
Commons.Types.AttributeValueCodec.Decode(value, Commons.Types.Enums.DataType.List, elementType);
}
catch (FormatException ex)
{
throw new ManagementCommandException(
$"List attribute '{name}' has an invalid list value: {ex.Message}");
}
}
}
else if (elementType is not null)
{
throw new ManagementCommandException($"Attribute '{name}': ElementDataType is only valid on List attributes.");
}
}
private static async Task HandleDeleteAttribute(IServiceProvider sp, DeleteTemplateAttributeCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var result = await svc.DeleteAttributeAsync(cmd.AttributeId, user);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task HandleAddAlarm(IServiceProvider sp, AddTemplateAlarmCommand cmd, string user)
{
var svc = sp.GetRequiredService();
var alarm = new TemplateAlarm(cmd.Name)
{
TriggerType = ParseEnumStrict