f86eb61d1a
Design D6 / §9 says a Live run is a Deployer act while Replay needs only
OperationalAudit, but the page and all three FlowDebug* commands were gated on
Deployer. The Audit Log and Execution Chain pages -- themselves OperationalAudit
-- show an "Open in Flow Debugger" drill-in that an auditor could not follow.
Implements the split:
- New `RequireFlowDebugger` policy over `FlowDebuggerAccessRoles`
{Deployer, Administrator, Viewer}; the page now carries it instead of
`RequireDeployment`.
- `FlowDebugReplayCommand` takes the same role set in the ManagementActor
matrix; `FlowDebugRunMethodCommand` / `FlowDebugRunValueChangeCommand` stay
DeployerOnly.
- The page resolves `_canLive` (Deployer) in OnInitializedAsync and, without it,
pins the mode to Replay, hides the entry-point and mode toggles, the Run
button and the Live pickers, honours only the `?executionId=` drill-in, and
leaves the parked-node Retry/Discard callbacks unset so those buttons never
render. `CanRun`, `SetMode(Live)` and `ActOnStepAsync` repeat the check so the
split does not rest on markup alone.
The NavMenu entry stays inside the Deployer-gated Deployment section -- auditors
arrive through the audit drill-ins, which is the point of the finding.
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
4242 lines
221 KiB
C#
4242 lines
221 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public class ManagementActor : ReceiveActor
|
||
{
|
||
private readonly IServiceProvider _serviceProvider;
|
||
private readonly ILogger<ManagementActor> _logger;
|
||
|
||
/// <summary>Initializes a new instance of <see cref="ManagementActor"/> and registers the message handler.</summary>
|
||
/// <param name="serviceProvider">DI service provider for scoped repository and service access.</param>
|
||
/// <param name="logger">Logger instance.</param>
|
||
public ManagementActor(IServiceProvider serviceProvider, ILogger<ManagementActor> logger)
|
||
{
|
||
_serviceProvider = serviceProvider;
|
||
_logger = logger;
|
||
Receive<ManagementEnvelope>(HandleEnvelope);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds the supervision strategy for <see cref="ManagementActor"/>. 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. <see cref="ManagementActor"/> spawns
|
||
/// no children today, but declaring the strategy explicitly matches the convention and
|
||
/// makes the contract correct ahead of any future worker actors.
|
||
/// </summary>
|
||
/// <returns>A one-for-one Resume strategy with no retry limit.</returns>
|
||
public static SupervisorStrategy CreateSupervisorStrategy() =>
|
||
new OneForOneStrategy(
|
||
maxNrOfRetries: -1,
|
||
withinTimeRange: System.Threading.Timeout.InfiniteTimeSpan,
|
||
decider: Decider.From(_ => Directive.Resume));
|
||
|
||
/// <inheritdoc />
|
||
protected override SupervisorStrategy SupervisorStrategy() => CreateSupervisorStrategy();
|
||
|
||
/// <summary>
|
||
/// Serializer settings for command results. <see cref="ReferenceHandler.IgnoreCycles"/>
|
||
/// keeps EF-backed entity graphs with bidirectional navigation properties from throwing;
|
||
/// camelCase matches what the CLI / HTTP layer expect.
|
||
/// </summary>
|
||
private static readonly JsonSerializerOptions ResultSerializerOptions = new()
|
||
{
|
||
ReferenceHandler = ReferenceHandler.IgnoreCycles,
|
||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||
};
|
||
|
||
/// <summary>
|
||
/// Serializes a command result to JSON using <see cref="System.Text.Json"/> — the same
|
||
/// serializer the HTTP endpoint uses — with cycle-safe settings.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Rejects tuple results outright. <see cref="System.Text.Json"/> writes only
|
||
/// properties by default, and a <c>ValueTuple</c> exposes its members as public
|
||
/// <em>fields</em> — so a handler that returns a repository's
|
||
/// <c>(rows, totalCount)</c> tuple serializes to the literal <c>{}</c>, silently
|
||
/// handing every caller an empty result with no error anywhere in the stack. That is
|
||
/// exactly how <c>audit-config query</c> 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.
|
||
/// </remarks>
|
||
/// <param name="result">The command result object to serialize, or null.</param>
|
||
/// <returns>The JSON string representation of <paramref name="result"/>, or <c>"null"</c> when null.</returns>
|
||
/// <exception cref="InvalidOperationException">
|
||
/// Thrown when <paramref name="result"/> is a tuple, which cannot round-trip through
|
||
/// the JSON contract.
|
||
/// </exception>
|
||
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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private async Task<object> ProcessCommand(ManagementEnvelope envelope, AuthenticatedUser user)
|
||
{
|
||
using var scope = _serviceProvider.CreateScope();
|
||
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user, envelope.ClientIp);
|
||
return new ManagementSuccess(envelope.CorrelationId, SerializeResult(result));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Maps an exception from command processing to the appropriate management response.
|
||
/// </summary>
|
||
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);
|
||
}
|
||
|
||
// 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
|
||
// two 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];
|
||
|
||
/// <summary>
|
||
/// Returns the set of roles that satisfy the authorization gate for
|
||
/// <paramref name="command"/> (any-of semantics), or <c>null</c> when the
|
||
/// command is available to any authenticated user. Internal so the
|
||
/// authorization matrix can be asserted directly in tests.
|
||
/// </summary>
|
||
/// <param name="command">The management command to authorize.</param>
|
||
/// <returns>The set of roles that satisfy the authorization gate, or <c>null</c> if any authenticated user may issue the command.</returns>
|
||
internal static IReadOnlyList<string>? GetRequiredRoles(object command) => command switch
|
||
{
|
||
// 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). A Live run executes
|
||
// scripts and writes attributes on operator input, so it stays 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 => 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 async Task<object?> DispatchCommand(
|
||
IServiceProvider sp, object command, AuthenticatedUser user, string? clientIp = null)
|
||
{
|
||
return command switch
|
||
{
|
||
// 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.Username),
|
||
DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd, user.Username),
|
||
UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user.Username),
|
||
MoveAreaCommand cmd => await HandleMoveArea(sp, cmd, user.Username),
|
||
|
||
// 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<FlowDebug.FlowDebugRunner>().RunMethodAsync(sp, cmd, user, CancellationToken.None),
|
||
FlowDebugRunValueChangeCommand cmd => await sp.GetRequiredService<FlowDebug.FlowDebugRunner>().RunValueChangeAsync(sp, cmd, user, CancellationToken.None),
|
||
FlowDebugReplayCommand cmd => await sp.GetRequiredService<FlowDebug.FlowDebugRunner>().ReplayAsync(sp, cmd, CancellationToken.None),
|
||
|
||
// 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
|
||
// ========================================================================
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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}.");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Register row 98. The scope gate used to be fail-OPEN on an unresolvable id —
|
||
/// the resolve-then-check shape was written as <c>if (x != null) EnforceSiteScope(…)</c>,
|
||
/// 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.
|
||
///
|
||
/// <para>
|
||
/// <b>System-wide callers are untouched.</b> Administrator, or a Deployer with no
|
||
/// site grants (the rule documented on <see cref="EnforceSiteScope"/>), 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.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>The denial is deliberately indistinguishable from an out-of-scope one.</b>
|
||
/// It raises the same <see cref="SiteScopeViolationException"/> (→
|
||
/// <c>ManagementUnauthorized</c>), and <paramref name="targetDescription"/> must
|
||
/// never state whether the target exists — otherwise the gate becomes an
|
||
/// enumeration oracle letting a scoped caller probe ids across the whole fleet.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="user">The authenticated caller.</param>
|
||
/// <param name="targetDescription">Caller-safe description of the target, e.g. <c>"instance 42"</c>.</param>
|
||
/// <exception cref="SiteScopeViolationException">The caller is site-scoped.</exception>
|
||
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)}].");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="DenyUnresolvedTargetIfScoped"/>).
|
||
/// </summary>
|
||
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<ITemplateEngineRepository>();
|
||
var instance = await repo.GetInstanceByIdAsync(instanceId);
|
||
if (instance is null)
|
||
{
|
||
DenyUnresolvedTargetIfScoped(user, $"instance {instanceId}");
|
||
return;
|
||
}
|
||
|
||
EnforceSiteScope(user, instance.SiteId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="DenyUnresolvedTargetIfScoped"/>).
|
||
/// </summary>
|
||
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<ISiteRepository>();
|
||
var site = await repo.GetSiteByIdentifierAsync(siteIdentifier);
|
||
if (site is null)
|
||
{
|
||
DenyUnresolvedTargetIfScoped(user, $"site '{siteIdentifier}'");
|
||
return;
|
||
}
|
||
|
||
EnforceSiteScope(user, site.Id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Logs an audit entry after a successful mutation.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Audit-logging contract. Every mutating operation is
|
||
/// audited exactly once, by whichever layer owns the write:
|
||
/// <list type="bullet">
|
||
/// <item>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.</item>
|
||
/// <item>Handlers that delegate to a domain service (<c>TemplateService</c>,
|
||
/// <c>SharedScriptService</c>, <c>InstanceService</c>, <c>AreaService</c>,
|
||
/// <c>SiteService</c>, <c>TemplateFolderService</c>, <c>DeploymentService</c>,
|
||
/// <c>ArtifactDeploymentService</c>) do NOT call this helper — those services own their
|
||
/// own <see cref="IAuditService"/> dependency and audit internally. Adding an explicit
|
||
/// <see cref="AuditAsync"/> call in such a handler would double-log.</item>
|
||
/// </list>
|
||
/// </remarks>
|
||
private static async Task AuditAsync(IServiceProvider sp, string user, string action, string entityType, string entityId, string entityName, object? afterState)
|
||
{
|
||
var auditService = sp.GetRequiredService<IAuditService>();
|
||
await auditService.LogAsync(user, action, entityType, entityId, entityName, afterState);
|
||
}
|
||
|
||
// ========================================================================
|
||
// Template handlers
|
||
// ========================================================================
|
||
|
||
private static async Task<object?> HandleListTemplates(IServiceProvider sp, ListTemplatesCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
|
||
// 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<T> Page<T>(IEnumerable<T> source, int skip, int? take) =>
|
||
source
|
||
.Skip(Math.Max(0, skip))
|
||
.Take(take is > 0 and <= 1000 ? take.Value : int.MaxValue)
|
||
.ToList();
|
||
|
||
private static async Task<object?> HandleGetTemplate(IServiceProvider sp, GetTemplateCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
return await repo.GetTemplateWithChildrenAsync(cmd.TemplateId);
|
||
}
|
||
|
||
private static async Task<object?> HandleCreateTemplate(IServiceProvider sp, CreateTemplateCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
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<object?> HandleUpdateTemplate(IServiceProvider sp, UpdateTemplateCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
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<object?> HandleDeleteTemplate(IServiceProvider sp, DeleteTemplateCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
var result = await svc.DeleteTemplateAsync(cmd.TemplateId, user);
|
||
return result.IsSuccess
|
||
? result.Value
|
||
: throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleValidateTemplate(IServiceProvider sp, ValidateTemplateCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
|
||
// 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<ISharedSchemaRepository>();
|
||
var sharedSchemas = await sharedSchemaRepo.ListAsync();
|
||
var schemaLibrary = sharedSchemas.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
|
||
Func<string, string?> 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<TemplateService>();
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private static async Task<object?> HandleGetResolvedTemplateMembers(IServiceProvider sp, GetResolvedTemplateMembersCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
var allTemplates = await repo.GetAllTemplatesAsync();
|
||
return TemplateInheritanceResolver.Resolve(cmd.TemplateId, allTemplates);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private static async Task<object?> HandleResyncInheritedMembers(IServiceProvider sp, ResyncInheritedMembersCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
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<object?> HandleListTemplateFolders(IServiceProvider sp)
|
||
{
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
return await repo.GetAllFoldersAsync();
|
||
}
|
||
|
||
private static async Task<object?> HandleCreateTemplateFolder(IServiceProvider sp, CreateTemplateFolderCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateFolderService>();
|
||
var result = await svc.CreateFolderAsync(cmd.Name, cmd.ParentFolderId, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleRenameTemplateFolder(IServiceProvider sp, RenameTemplateFolderCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateFolderService>();
|
||
var result = await svc.RenameFolderAsync(cmd.FolderId, cmd.NewName, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleMoveTemplateFolder(IServiceProvider sp, MoveTemplateFolderCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateFolderService>();
|
||
var result = await svc.MoveFolderAsync(cmd.FolderId, cmd.NewParentFolderId, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleReorderTemplateFolder(IServiceProvider sp, ReorderTemplateFolderCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateFolderService>();
|
||
var result = await svc.ReorderFolderAsync(cmd.FolderId, cmd.Direction, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteTemplateFolder(IServiceProvider sp, DeleteTemplateFolderCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateFolderService>();
|
||
var result = await svc.DeleteFolderAsync(cmd.FolderId, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleMoveTemplateToFolder(IServiceProvider sp, MoveTemplateToFolderCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
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<object?> HandleListInstances(IServiceProvider sp, ListInstancesCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var repo = sp.GetRequiredService<ICentralUiRepository>();
|
||
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<string>(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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads one instance, scope-checked against its own site.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// This handler resolves the instance ITSELF rather than calling
|
||
/// <see cref="EnforceSiteScopeForInstance"/> — 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.
|
||
/// </remarks>
|
||
private static async Task<object?> HandleGetInstance(IServiceProvider sp, GetInstanceCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
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<object?> HandleCreateInstance(IServiceProvider sp, CreateInstanceCommand cmd, AuthenticatedUser user)
|
||
{
|
||
EnforceSiteScope(user, cmd.SiteId);
|
||
var svc = sp.GetRequiredService<InstanceService>();
|
||
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<object?> HandleDeployInstance(IServiceProvider sp, MgmtDeployInstanceCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var svc = sp.GetRequiredService<DeploymentService>();
|
||
var result = await svc.DeployInstanceAsync(cmd.InstanceId, user.Username);
|
||
return result.IsSuccess
|
||
? result.Value
|
||
: throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Bulk-deploys every deployable instance at a site.
|
||
///
|
||
/// <para>
|
||
/// 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.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <c>Force</c> 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.
|
||
/// </para>
|
||
/// </summary>
|
||
private static async Task<object?> HandleDeploySite(IServiceProvider sp, MgmtDeploySiteCommand cmd, AuthenticatedUser user)
|
||
{
|
||
EnforceSiteScope(user, cmd.SiteId);
|
||
var svc = sp.GetRequiredService<DeploymentService>();
|
||
var result = await svc.DeploySiteAsync(cmd.SiteId, user.Username, cmd.Force);
|
||
return result.IsSuccess
|
||
? result.Value
|
||
: throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleEnableInstance(IServiceProvider sp, MgmtEnableInstanceCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var svc = sp.GetRequiredService<DeploymentService>();
|
||
var result = await svc.EnableInstanceAsync(cmd.InstanceId, user.Username);
|
||
return result.IsSuccess
|
||
? result.Value
|
||
: throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleDisableInstance(IServiceProvider sp, MgmtDisableInstanceCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var svc = sp.GetRequiredService<DeploymentService>();
|
||
var result = await svc.DisableInstanceAsync(cmd.InstanceId, user.Username);
|
||
return result.IsSuccess
|
||
? result.Value
|
||
: throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteInstance(IServiceProvider sp, MgmtDeleteInstanceCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var svc = sp.GetRequiredService<DeploymentService>();
|
||
var result = await svc.DeleteInstanceAsync(cmd.InstanceId, user.Username);
|
||
return result.IsSuccess
|
||
? result.Value
|
||
: throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleSetConnectionBindings(IServiceProvider sp, SetConnectionBindingsCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var svc = sp.GetRequiredService<InstanceService>();
|
||
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<object?> 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<ITemplateEngineRepository>();
|
||
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<InstanceAttributeOverride>(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<IAuditService>();
|
||
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<object?> HandleSetInstanceArea(IServiceProvider sp, SetInstanceAreaCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var svc = sp.GetRequiredService<InstanceService>();
|
||
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<object?> HandleSetInstanceAlarmOverride(IServiceProvider sp, SetInstanceAlarmOverrideCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var svc = sp.GetRequiredService<InstanceService>();
|
||
var result = await svc.SetAlarmOverrideAsync(
|
||
cmd.InstanceId, cmd.AlarmCanonicalName,
|
||
cmd.TriggerConfigurationOverride, cmd.PriorityLevelOverride,
|
||
user.Username);
|
||
return result.IsSuccess
|
||
? result.Value
|
||
: throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteInstanceAlarmOverride(IServiceProvider sp, DeleteInstanceAlarmOverrideCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var svc = sp.GetRequiredService<InstanceService>();
|
||
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<object?> HandleListInstanceAlarmOverrides(IServiceProvider sp, ListInstanceAlarmOverridesCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
return await repo.GetAlarmOverridesByInstanceIdAsync(cmd.InstanceId);
|
||
}
|
||
|
||
private static async Task<object?> HandleSetInstanceNativeAlarmSourceOverride(
|
||
IServiceProvider sp, SetInstanceNativeAlarmSourceOverrideCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
|
||
// 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<IFlatteningPipeline>();
|
||
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<object?> HandleSetInstanceNativeAlarmSourceOverrides(
|
||
IServiceProvider sp, SetInstanceNativeAlarmSourceOverridesCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
|
||
// 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<IFlatteningPipeline>();
|
||
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<string>(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<InstanceNativeAlarmSourceOverride>();
|
||
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<object?> HandleDeleteInstanceNativeAlarmSourceOverride(
|
||
IServiceProvider sp, DeleteInstanceNativeAlarmSourceOverrideCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
|
||
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<object?> HandleListInstanceNativeAlarmSourceOverrides(
|
||
IServiceProvider sp, ListInstanceNativeAlarmSourceOverridesCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
return await repo.GetNativeAlarmSourceOverridesByInstanceIdAsync(cmd.InstanceId);
|
||
}
|
||
|
||
private static async Task<object?> HandleGetDeploymentDiff(IServiceProvider sp, GetDeploymentDiffCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||
var svc = sp.GetRequiredService<DeploymentService>();
|
||
var result = await svc.GetDeploymentComparisonAsync(cmd.InstanceId);
|
||
return result.IsSuccess
|
||
? result.Value
|
||
: throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleRetryParkedMessage(IServiceProvider sp, RetryParkedMessageCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);
|
||
var commService = sp.GetRequiredService<CommunicationService>();
|
||
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<object?> HandleDiscardParkedMessage(IServiceProvider sp, DiscardParkedMessageCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);
|
||
var commService = sp.GetRequiredService<CommunicationService>();
|
||
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);
|
||
|
||
/// <summary>
|
||
/// Deterministic, reversible map from a <see cref="PendingSecuredWrite.Id"/> (a
|
||
/// store-assigned <see cref="long"/>) to the canonical AuditLog
|
||
/// <c>CorrelationId</c> (a <see cref="Guid"/>): 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).
|
||
/// </summary>
|
||
private static Guid SecuredWriteCorrelation(long id)
|
||
{
|
||
Span<byte> bytes = stackalloc byte[16];
|
||
BinaryPrimitives.WriteInt64BigEndian(bytes[8..], id);
|
||
return new Guid(bytes);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Best-effort emission of ONE secured-write AuditLog row via the central direct-write
|
||
/// path (<see cref="ICentralAuditWriter.WriteAsync"/>) — mirrors the Notification
|
||
/// Outbox dispatch / Inbound API central-origin pattern. The row is built through the
|
||
/// canonical <see cref="ScadaBridgeAuditEventFactory"/> so Action/Category/Outcome map
|
||
/// identically to every other emit site.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Routes through <see cref="ICentralAuditWriter"/> (rather than calling
|
||
/// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> directly) so the writing
|
||
/// central node's <c>SourceNode</c> (<c>central-a</c>/<c>central-b</c>) is stamped via
|
||
/// the writer's <see cref="INodeIdentityProvider"/> — satisfying the design's
|
||
/// node-of-origin invariant. The rows are otherwise unchanged (same channel/kind,
|
||
/// same <c>CorrelationId</c> = the row id, same payload).
|
||
/// <para>
|
||
/// Standing audit invariant: an audit-write failure NEVER aborts the secured-write
|
||
/// action. <see cref="ICentralAuditWriter"/> 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.
|
||
/// </para>
|
||
/// </remarks>
|
||
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<ICentralAuditWriter>();
|
||
await auditWriter.WriteAsync(evt);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Audit is best-effort — swallow + log; never abort the secured-write action.
|
||
sp.GetService<ILogger<ManagementActor>>()?.LogWarning(
|
||
ex,
|
||
"Best-effort secured-write audit emission failed (kind={Kind}, id={Id}); the write itself is unaffected.",
|
||
kind, row.Id);
|
||
}
|
||
}
|
||
|
||
/// <summary>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).</summary>
|
||
private static async Task<bool> TryExpireIfStaleAsync(
|
||
IServiceProvider sp, ISecuredWriteRepository repo, PendingSecuredWrite row)
|
||
{
|
||
var ttl = sp.GetService<IOptions<ManagementServiceOptions>>()?.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<object?> 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<ISiteRepository>();
|
||
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<ISecuredWriteRepository>();
|
||
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<object?> HandleApproveSecuredWrite(
|
||
IServiceProvider sp, ApproveSecuredWriteCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISecuredWriteRepository>();
|
||
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<ISiteRepository>();
|
||
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<Commons.Types.Enums.DataType>(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<CommunicationService>();
|
||
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<object?> HandleRejectSecuredWrite(
|
||
IServiceProvider sp, RejectSecuredWriteCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISecuredWriteRepository>();
|
||
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<object?> 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<string>? 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<ISiteRepository>();
|
||
var permitted = new HashSet<string>(user.PermittedSiteIds);
|
||
permittedIdentifiers = (await siteRepo.GetAllSitesAsync())
|
||
.Where(s => permitted.Contains(s.Id.ToString()))
|
||
.Select(s => s.SiteIdentifier)
|
||
.ToList();
|
||
}
|
||
|
||
var repo = sp.GetRequiredService<ISecuredWriteRepository>();
|
||
|
||
// 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<object?> HandleListSites(IServiceProvider sp, AuthenticatedUser user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||
var sites = await repo.GetAllSitesAsync();
|
||
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
||
{
|
||
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
|
||
sites = sites.Where(s => permittedIds.Contains(s.Id.ToString())).ToList();
|
||
}
|
||
return sites;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads one site, scope-checked against itself. Fail-CLOSED on an unresolvable
|
||
/// id for a scoped caller — see <see cref="DenyUnresolvedTargetIfScoped"/>
|
||
/// (register row 98).
|
||
/// </summary>
|
||
private static async Task<object?> HandleGetSite(IServiceProvider sp, GetSiteCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||
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<object?> HandleCreateSite(IServiceProvider sp, CreateSiteCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||
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<CommunicationService>();
|
||
commService?.RefreshSiteAddresses();
|
||
await AuditAsync(sp, user, "Create", "Site", site.Id.ToString(), site.Name, site);
|
||
return site;
|
||
}
|
||
|
||
private static async Task<object?> HandleUpdateSite(IServiceProvider sp, UpdateSiteCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||
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<CommunicationService>();
|
||
commService?.RefreshSiteAddresses();
|
||
await AuditAsync(sp, user, "Update", "Site", site.Id.ToString(), site.Name, site);
|
||
return site;
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteSite(IServiceProvider sp, DeleteSiteCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||
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<CommunicationService>();
|
||
commService?.RefreshSiteAddresses();
|
||
|
||
// 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<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcClientFactory>();
|
||
if (grpcFactory is not null && site is not null)
|
||
await 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<object?> HandleListAreas(IServiceProvider sp, ListAreasCommand cmd, AuthenticatedUser user)
|
||
{
|
||
EnforceSiteScope(user, cmd.SiteId);
|
||
var repo = sp.GetRequiredService<ICentralUiRepository>();
|
||
return await repo.GetAreaTreeBySiteIdAsync(cmd.SiteId);
|
||
}
|
||
|
||
private static async Task<object?> HandleCreateArea(IServiceProvider sp, CreateAreaCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
var area = new Area(cmd.Name)
|
||
{
|
||
SiteId = cmd.SiteId,
|
||
ParentAreaId = cmd.ParentAreaId
|
||
};
|
||
await repo.AddAreaAsync(area);
|
||
await repo.SaveChangesAsync();
|
||
await AuditAsync(sp, user, "Create", "Area", area.Id.ToString(), area.Name, area);
|
||
return area;
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteArea(IServiceProvider sp, DeleteAreaCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
await repo.DeleteAreaAsync(cmd.AreaId);
|
||
await repo.SaveChangesAsync();
|
||
await AuditAsync(sp, user, "Delete", "Area", cmd.AreaId.ToString(), cmd.AreaId.ToString(), null);
|
||
return true;
|
||
}
|
||
|
||
// ========================================================================
|
||
// Data Connection handlers
|
||
// ========================================================================
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="SmtpConfigPublicShape"/>. 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.
|
||
/// </summary>
|
||
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,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Lists data connections, constrained to the caller's permitted sites.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// 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
|
||
/// <see cref="HandleGetDataConnection"/> — Get denied exactly what List handed
|
||
/// over, and the secret-elided <see cref="DataConnectionPublicShape"/> still
|
||
/// leaked site topology plus an id-existence oracle. Scoping now mirrors
|
||
/// <see cref="HandleListSites"/>: 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.
|
||
/// </remarks>
|
||
private static async Task<object?> HandleListDataConnections(
|
||
IServiceProvider sp, ListDataConnectionsCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||
if (cmd.SiteId.HasValue)
|
||
{
|
||
EnforceSiteScope(user, cmd.SiteId.Value);
|
||
return (await repo.GetDataConnectionsBySiteIdAsync(cmd.SiteId.Value)).Select(DataConnectionPublicShape).ToList();
|
||
}
|
||
|
||
IEnumerable<DataConnection> connections = await repo.GetAllDataConnectionsAsync();
|
||
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
||
{
|
||
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
|
||
connections = connections.Where(c => permittedIds.Contains(c.SiteId.ToString()));
|
||
}
|
||
|
||
return connections.Select(DataConnectionPublicShape).ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads one data connection, scope-checked against its owning site. Fail-CLOSED
|
||
/// on an unresolvable id for a scoped caller — see
|
||
/// <see cref="DenyUnresolvedTargetIfScoped"/> (register row 98).
|
||
/// </summary>
|
||
private static async Task<object?> HandleGetDataConnection(IServiceProvider sp, GetDataConnectionCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||
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<object?> HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||
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<object?> HandleUpdateDataConnection(IServiceProvider sp, UpdateDataConnectionCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||
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<object?> HandleDeleteDataConnection(IServiceProvider sp, DeleteDataConnectionCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||
await repo.DeleteDataConnectionAsync(cmd.DataConnectionId);
|
||
await repo.SaveChangesAsync();
|
||
await AuditAsync(sp, user, "Delete", "DataConnection", cmd.DataConnectionId.ToString(), cmd.DataConnectionId.ToString(), null);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
private static async Task<object?> HandleMoveDataConnection(IServiceProvider sp, MoveDataConnectionCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISiteRepository>();
|
||
|
||
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<string>();
|
||
|
||
// 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<ITemplateEngineRepository>();
|
||
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
|
||
// ========================================================================
|
||
|
||
/// <summary>
|
||
/// Secret-elided ExternalSystemDefinition projection (arch-review C3): the
|
||
/// opaque <c>AuthConfiguration</c> blob holds API keys / Basic credentials and
|
||
/// must never leave this boundary via responses or audit afterState. Presence
|
||
/// is surfaced as <c>HasAuthConfiguration</c>; every non-secret field is
|
||
/// projected verbatim. Mirrors <see cref="SmtpConfigPublicShape"/>.
|
||
/// </summary>
|
||
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<object?> HandleListExternalSystems(IServiceProvider sp)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
return (await repo.GetAllExternalSystemsAsync()).Select(ExternalSystemPublicShape).ToList();
|
||
}
|
||
|
||
private static async Task<object?> HandleGetExternalSystem(IServiceProvider sp, GetExternalSystemCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
var def = await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId);
|
||
return def is null ? null : ExternalSystemPublicShape(def);
|
||
}
|
||
|
||
private static async Task<object?> HandleCreateExternalSystem(IServiceProvider sp, CreateExternalSystemCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
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<object?> HandleUpdateExternalSystem(IServiceProvider sp, UpdateExternalSystemCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
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<object?> HandleDeleteExternalSystem(IServiceProvider sp, DeleteExternalSystemCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
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<object?> HandleListExternalSystemMethods(IServiceProvider sp, ListExternalSystemMethodsCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
return await repo.GetMethodsByExternalSystemIdAsync(cmd.ExternalSystemId);
|
||
}
|
||
|
||
private static async Task<object?> HandleGetExternalSystemMethod(IServiceProvider sp, GetExternalSystemMethodCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
return await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId);
|
||
}
|
||
|
||
private static async Task<object?> HandleCreateExternalSystemMethod(IServiceProvider sp, CreateExternalSystemMethodCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
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<object?> HandleUpdateExternalSystemMethod(IServiceProvider sp, UpdateExternalSystemMethodCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
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<object?> HandleDeleteExternalSystemMethod(IServiceProvider sp, DeleteExternalSystemMethodCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
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<object?> HandleListNotificationLists(IServiceProvider sp)
|
||
{
|
||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||
return await repo.GetAllNotificationListsAsync();
|
||
}
|
||
|
||
private static async Task<object?> HandleGetNotificationList(IServiceProvider sp, GetNotificationListCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||
return await repo.GetNotificationListByIdAsync(cmd.NotificationListId);
|
||
}
|
||
|
||
private static async Task<object?> HandleCreateNotificationList(IServiceProvider sp, CreateNotificationListCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||
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<object?> HandleUpdateNotificationList(IServiceProvider sp, UpdateNotificationListCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||
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<object?> HandleDeleteNotificationList(IServiceProvider sp, DeleteNotificationListCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||
await repo.DeleteNotificationListAsync(cmd.NotificationListId);
|
||
await repo.SaveChangesAsync();
|
||
await AuditAsync(sp, user, "Delete", "NotificationList", cmd.NotificationListId.ToString(), cmd.NotificationListId.ToString(), null);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// SMS Notifications: build the recipient set for a notification list according
|
||
/// to its delivery channel. Email lists map each <paramref name="recipientEmails"/>
|
||
/// entry to an <see cref="NotificationRecipient.ForEmail"/> recipient; SMS lists map
|
||
/// each <paramref name="recipientPhones"/> entry to an <see cref="NotificationRecipient.ForSms"/>
|
||
/// recipient. The off-channel source list is ignored so an Email list never stores a
|
||
/// phone in EmailAddress (and vice versa).
|
||
/// </summary>
|
||
private static IEnumerable<NotificationRecipient> BuildRecipients(
|
||
NotificationType type, IReadOnlyList<string> recipientEmails, IReadOnlyList<string>? recipientPhones)
|
||
{
|
||
if (type == NotificationType.Sms)
|
||
{
|
||
foreach (var phone in recipientPhones ?? Array.Empty<string>())
|
||
{
|
||
yield return NotificationRecipient.ForSms(phone, phone);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
foreach (var email in recipientEmails)
|
||
{
|
||
yield return NotificationRecipient.ForEmail(email, email);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
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<object?> HandleListSmtpConfigs(IServiceProvider sp)
|
||
{
|
||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||
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<object?> HandleUpdateSmtpConfig(IServiceProvider sp, UpdateSmtpConfigCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||
var config = 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);
|
||
await repo.UpdateSmtpConfigurationAsync(config);
|
||
await repo.SaveChangesAsync();
|
||
// Audit the credential-free shape — the *fact of* the change
|
||
// (and which non-secret fields hold) is observable; the secret value is
|
||
// not persisted to the audit log where OperationalAuditRoles can read it.
|
||
var publicShape = SmtpConfigPublicShape(config);
|
||
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, publicShape);
|
||
return publicShape;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
/// <param name="config">The configuration with all command assignments already applied.</param>
|
||
/// <exception cref="ManagementCommandException">The transport is unknown, or the EWS shape rules are violated.</exception>
|
||
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').");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="SmtpConfigPublicShape"/>.
|
||
/// </summary>
|
||
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<object?> HandleListSmsConfigs(IServiceProvider sp)
|
||
{
|
||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||
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<object?> HandleUpdateSmsConfig(IServiceProvider sp, UpdateSmsConfigCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<INotificationRepository>();
|
||
var configs = await repo.GetAllSmsConfigurationsAsync();
|
||
var config = 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;
|
||
await repo.UpdateSmsConfigurationAsync(config);
|
||
await repo.SaveChangesAsync();
|
||
// Audit the credential-free shape — the AuthToken secret is
|
||
// not persisted to the audit log where OperationalAuditRoles can read it.
|
||
var publicShape = SmsConfigPublicShape(config);
|
||
await AuditAsync(sp, user, "Update", "SmsConfiguration", config.Id.ToString(), config.AccountSid, publicShape);
|
||
return publicShape;
|
||
}
|
||
|
||
// ========================================================================
|
||
// Security handlers
|
||
// ========================================================================
|
||
|
||
private static async Task<object?> HandleListRoleMappings(IServiceProvider sp)
|
||
{
|
||
var repo = sp.GetRequiredService<ISecurityRepository>();
|
||
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<object?> HandleCreateRoleMapping(IServiceProvider sp, CreateRoleMappingCommand cmd, string user)
|
||
{
|
||
var role = CanonicalizeMappingRole(cmd.Role);
|
||
var repo = sp.GetRequiredService<ISecurityRepository>();
|
||
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<object?> HandleUpdateRoleMapping(IServiceProvider sp, UpdateRoleMappingCommand cmd, string user)
|
||
{
|
||
var role = CanonicalizeMappingRole(cmd.Role);
|
||
var repo = sp.GetRequiredService<ISecurityRepository>();
|
||
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<object?> HandleDeleteRoleMapping(IServiceProvider sp, DeleteRoleMappingCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISecurityRepository>();
|
||
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<object?> 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<IInboundApiKeyAdmin>();
|
||
var keys = await admin.ListAsync();
|
||
return keys
|
||
.Select(k => new { k.KeyId, k.Name, k.Enabled, k.Methods })
|
||
.ToList();
|
||
}
|
||
|
||
private static async Task<object?> 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_<keyId>_<secret>).
|
||
// 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<IInboundApiKeyAdmin>();
|
||
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<object?> HandleDeleteApiKey(IServiceProvider sp, DeleteApiKeyCommand cmd, string user)
|
||
{
|
||
var admin = sp.GetRequiredService<IInboundApiKeyAdmin>();
|
||
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<object?> 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<ArtifactDeploymentService>();
|
||
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<object?> HandleQueryDeployments(IServiceProvider sp, QueryDeploymentsCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var repo = sp.GetRequiredService<IDeploymentManagerRepository>();
|
||
|
||
// 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<DeploymentService>() 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs the shared paged deployment read and wraps it in the command's result
|
||
/// envelope, echoing the page coordinates that were actually applied.
|
||
/// </summary>
|
||
private static async Task<DeploymentQueryResult> 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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolves the caller's site-scope grant into the query's
|
||
/// <see cref="DeploymentListFilter.SiteIdScope"/>: <see langword="null"/> for a
|
||
/// system-wide caller, otherwise the permitted site ids.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Administrator is system-wide here, matching <see cref="EnforceSiteScope"/>
|
||
/// 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.
|
||
/// </remarks>
|
||
private static IReadOnlyCollection<int>? ResolveSiteIdScope(AuthenticatedUser user)
|
||
{
|
||
if (user.PermittedSiteIds.Length == 0) return null;
|
||
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return null;
|
||
|
||
var ids = new List<int>(user.PermittedSiteIds.Length);
|
||
foreach (var raw in user.PermittedSiteIds)
|
||
{
|
||
if (int.TryParse(raw, out var id))
|
||
ids.Add(id);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Bounds the requested deployment page size: a non-positive value means
|
||
/// "unspecified" and takes the default, and every value is capped at
|
||
/// <see cref="DeploymentListPage.MaxPageSize"/>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Register row 87. A non-positive size used to mean UNLIMITED all the way down
|
||
/// to the repository, so <c>--page-size 0</c> 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.
|
||
/// </remarks>
|
||
private static int ResolveDeploymentPageSize(int requested) =>
|
||
requested <= 0
|
||
? DeploymentListPage.DefaultPageSize
|
||
: Math.Min(requested, DeploymentListPage.MaxPageSize);
|
||
|
||
/// <summary>
|
||
/// Parses the wire <c>Status</c> filter into a <see cref="DeploymentStatus"/>.
|
||
/// Absent/blank means "no filter"; an unrecognised value is an ERROR naming the
|
||
/// valid ones.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Register row 88. An unparseable value used to fall back to "no filter", so
|
||
/// <c>--status Sucess</c> 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.
|
||
/// </remarks>
|
||
/// <exception cref="ManagementCommandException">The value is not a deployment status.</exception>
|
||
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<DeploymentStatus>(trimmed, ignoreCase: true, out var parsed)
|
||
&& Enum.IsDefined(parsed))
|
||
{
|
||
return parsed;
|
||
}
|
||
|
||
throw new ManagementCommandException(
|
||
$"Unknown deployment status '{status}'. Valid values: {string.Join(", ", Enum.GetNames<DeploymentStatus>())}.");
|
||
}
|
||
|
||
// ========================================================================
|
||
// Audit Log handler
|
||
// ========================================================================
|
||
|
||
/// <summary>
|
||
/// Queries the configuration-change audit trail and projects the repository's
|
||
/// ValueTuple into <see cref="AuditLogQueryResult"/>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The projection is load-bearing, not cosmetic: returning the repository tuple
|
||
/// directly made <see cref="SerializeResult"/> emit the literal <c>{}</c> (ValueTuple
|
||
/// exposes <c>Item1</c>/<c>Item2</c> as fields, which System.Text.Json skips), so
|
||
/// <c>audit-config query</c> reported an empty result for every filter combination
|
||
/// regardless of how many rows matched — deferred-work register row 46.
|
||
/// </remarks>
|
||
private static async Task<object?> HandleQueryAuditLog(IServiceProvider sp, QueryAuditLogCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<ICentralUiRepository>();
|
||
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<ICentralHealthAggregator>();
|
||
return aggregator.GetAllSiteStates();
|
||
}
|
||
|
||
private static object? HandleGetSiteHealth(IServiceProvider sp, GetSiteHealthCommand cmd)
|
||
{
|
||
var aggregator = sp.GetRequiredService<ICentralHealthAggregator>();
|
||
return aggregator.GetSiteState(cmd.SiteIdentifier);
|
||
}
|
||
|
||
// ========================================================================
|
||
// Template member handlers
|
||
// ========================================================================
|
||
|
||
private static async Task<object?> HandleAddAttribute(IServiceProvider sp, AddTemplateAttributeCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
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<object?> HandleUpdateAttribute(IServiceProvider sp, UpdateTemplateAttributeCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Parses a required data type token. Throws <see cref="ManagementCommandException"/>
|
||
/// (surfaced to the caller as a curated COMMAND_FAILED message) on an
|
||
/// unrecognised type name, rather than letting <c>Enum.Parse</c>'s
|
||
/// <see cref="ArgumentException"/> be masked as a generic internal error.
|
||
/// </summary>
|
||
private static Commons.Types.Enums.DataType ParseDataType(string? dataType)
|
||
=> ParseEnumStrict<Commons.Types.Enums.DataType>("data type", dataType);
|
||
|
||
/// <summary>
|
||
/// Parses an optional element data type token. Returns null when the token is
|
||
/// empty/whitespace; throws <see cref="ManagementCommandException"/> on an
|
||
/// unrecognised type name.
|
||
/// </summary>
|
||
private static Commons.Types.Enums.DataType? ParseElementDataType(string? elementDataType)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(elementDataType)) return null;
|
||
return ParseEnumStrict<Commons.Types.Enums.DataType>("element type", elementDataType);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="ManagementCommandException"/> naming the field and the
|
||
/// valid value set on any rejection.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Register row 88 pattern (see <see cref="ParseDeploymentStatus"/>). Plain
|
||
/// <c>Enum.TryParse</c>/<c>Enum.Parse</c> admits three traps a wire-contract
|
||
/// field must not tolerate:
|
||
/// <list type="number">
|
||
/// <item><b>Ordinals.</b> <c>"2"</c> parses to whatever member sits at that
|
||
/// position (e.g. <c>DataType.Float</c>) — a caller-facing field is a
|
||
/// name-based contract, never a positional one.</item>
|
||
/// <item><b>Undefined ordinals.</b> <c>"99"</c> parses to an undefined enum
|
||
/// value that saves without error and only detonates at deploy time.</item>
|
||
/// <item><b>Comma-joined tokens.</b> <c>Enum.TryParse</c> bitwise-ORs a
|
||
/// comma-separated string's pieces together regardless of whether the enum
|
||
/// is <c>[Flags]</c> — <c>"String,Int32"</c> (4|1) silently becomes
|
||
/// <c>DateTime</c> (5) for a dense 0-based enum. Any token containing a
|
||
/// comma is rejected outright, before <c>TryParse</c> ever sees it.</item>
|
||
/// </list>
|
||
/// Mirrors <see cref="Commons.Types.Audit.AuditQueryParamParsers.ParseEnumListStrict{TEnum}"/>,
|
||
/// adapted to a single required value and <see cref="ManagementCommandException"/>.
|
||
/// </remarks>
|
||
/// <exception cref="ManagementCommandException">The value is not a defined member name of <typeparamref name="TEnum"/>.</exception>
|
||
private static TEnum ParseEnumStrict<TEnum>(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<TEnum>(trimmed, ignoreCase: true, out var parsed)
|
||
&& Enum.IsDefined(parsed))
|
||
{
|
||
return parsed;
|
||
}
|
||
|
||
throw new ManagementCommandException(
|
||
$"Unrecognised {fieldName} '{raw}'. Valid values: {string.Join(", ", Enum.GetNames<TEnum>())}.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates the (DataType, ElementDataType, Value) triple shared by the add
|
||
/// and update attribute handlers. Throws <see cref="ManagementCommandException"/>
|
||
/// on any violation:
|
||
/// <list type="bullet">
|
||
/// <item>List attributes require a valid scalar element type.</item>
|
||
/// <item>Scalar attributes may not carry an element type.</item>
|
||
/// <item>A List default value must decode against the declared element type.</item>
|
||
/// </list>
|
||
/// </summary>
|
||
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<object?> HandleDeleteAttribute(IServiceProvider sp, DeleteTemplateAttributeCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
var result = await svc.DeleteAttributeAsync(cmd.AttributeId, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleAddAlarm(IServiceProvider sp, AddTemplateAlarmCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
var alarm = new TemplateAlarm(cmd.Name)
|
||
{
|
||
TriggerType = ParseEnumStrict<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTriggerType>("alarm trigger type", cmd.TriggerType),
|
||
PriorityLevel = cmd.PriorityLevel,
|
||
Description = cmd.Description,
|
||
TriggerConfiguration = cmd.TriggerConfiguration,
|
||
IsLocked = cmd.IsLocked
|
||
};
|
||
var result = await svc.AddAlarmAsync(cmd.TemplateId, alarm, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleUpdateAlarm(IServiceProvider sp, UpdateTemplateAlarmCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
var alarm = new TemplateAlarm(cmd.Name)
|
||
{
|
||
TriggerType = ParseEnumStrict<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTriggerType>("alarm trigger type", cmd.TriggerType),
|
||
PriorityLevel = cmd.PriorityLevel,
|
||
Description = cmd.Description,
|
||
TriggerConfiguration = cmd.TriggerConfiguration,
|
||
IsLocked = cmd.IsLocked
|
||
};
|
||
var result = await svc.UpdateAlarmAsync(cmd.AlarmId, alarm, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteAlarm(IServiceProvider sp, DeleteTemplateAlarmCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
var result = await svc.DeleteAlarmAsync(cmd.AlarmId, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
// ── Native alarm source bindings (read-only mirror; repository-direct CRUD) ──
|
||
|
||
private static async Task<object?> HandleAddNativeAlarmSource(IServiceProvider sp, AddTemplateNativeAlarmSourceCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
var source = new TemplateNativeAlarmSource(cmd.Name)
|
||
{
|
||
TemplateId = cmd.TemplateId,
|
||
ConnectionName = cmd.ConnectionName,
|
||
SourceReference = cmd.SourceReference,
|
||
ConditionFilter = cmd.ConditionFilter,
|
||
Description = cmd.Description,
|
||
IsLocked = cmd.IsLocked
|
||
};
|
||
await repo.AddTemplateNativeAlarmSourceAsync(source);
|
||
await repo.SaveChangesAsync();
|
||
// Propagate the new source to derived descendants. Native-source
|
||
// CRUD lives here (not TemplateService), so call the propagation directly.
|
||
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(cmd.TemplateId, user);
|
||
return source;
|
||
}
|
||
|
||
private static async Task<object?> HandleUpdateNativeAlarmSource(IServiceProvider sp, UpdateTemplateNativeAlarmSourceCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId)
|
||
?? throw new ManagementCommandException($"Native alarm source {cmd.NativeAlarmSourceId} not found.");
|
||
source.Name = cmd.Name;
|
||
source.ConnectionName = cmd.ConnectionName;
|
||
source.SourceReference = cmd.SourceReference;
|
||
source.ConditionFilter = cmd.ConditionFilter;
|
||
source.Description = cmd.Description;
|
||
source.IsLocked = cmd.IsLocked;
|
||
await repo.UpdateTemplateNativeAlarmSourceAsync(source);
|
||
await repo.SaveChangesAsync();
|
||
// Propagate the changed source to derived descendants.
|
||
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
|
||
return source;
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteNativeAlarmSource(IServiceProvider sp, DeleteTemplateNativeAlarmSourceCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
// Capture the owning template before delete so descendants can be reconciled.
|
||
var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId);
|
||
await repo.DeleteTemplateNativeAlarmSourceAsync(cmd.NativeAlarmSourceId);
|
||
await repo.SaveChangesAsync();
|
||
// Remove the now-orphaned inherited placeholder from derived descendants.
|
||
if (source != null)
|
||
await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
|
||
return cmd.NativeAlarmSourceId;
|
||
}
|
||
|
||
private static async Task<object?> HandleListNativeAlarmSources(IServiceProvider sp, ListTemplateNativeAlarmSourcesCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
return await repo.GetNativeAlarmSourcesByTemplateIdAsync(cmd.TemplateId);
|
||
}
|
||
|
||
private static async Task<object?> HandleAddScript(IServiceProvider sp, AddTemplateScriptCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
var script = new TemplateScript(cmd.Name, cmd.Code)
|
||
{
|
||
TriggerType = cmd.TriggerType,
|
||
TriggerConfiguration = cmd.TriggerConfiguration,
|
||
IsLocked = cmd.IsLocked,
|
||
ParameterDefinitions = cmd.ParameterDefinitions,
|
||
ReturnDefinition = cmd.ReturnDefinition,
|
||
MinTimeBetweenRuns = cmd.MinTimeBetweenRuns,
|
||
ExecutionTimeoutSeconds = cmd.ExecutionTimeoutSeconds is > 0 ? cmd.ExecutionTimeoutSeconds : null
|
||
};
|
||
var result = await svc.AddScriptAsync(cmd.TemplateId, script, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleUpdateScript(IServiceProvider sp, UpdateTemplateScriptCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
var script = new TemplateScript(cmd.Name, cmd.Code)
|
||
{
|
||
TriggerType = cmd.TriggerType,
|
||
TriggerConfiguration = cmd.TriggerConfiguration,
|
||
IsLocked = cmd.IsLocked,
|
||
ParameterDefinitions = cmd.ParameterDefinitions,
|
||
ReturnDefinition = cmd.ReturnDefinition,
|
||
MinTimeBetweenRuns = cmd.MinTimeBetweenRuns,
|
||
ExecutionTimeoutSeconds = cmd.ExecutionTimeoutSeconds is > 0 ? cmd.ExecutionTimeoutSeconds : null
|
||
};
|
||
var result = await svc.UpdateScriptAsync(cmd.ScriptId, script, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteScript(IServiceProvider sp, DeleteTemplateScriptCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
var result = await svc.DeleteScriptAsync(cmd.ScriptId, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleAddComposition(IServiceProvider sp, AddTemplateCompositionCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
var result = await svc.AddCompositionAsync(cmd.TemplateId, cmd.ComposedTemplateId, cmd.InstanceName, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteComposition(IServiceProvider sp, DeleteTemplateCompositionCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<TemplateService>();
|
||
var result = await svc.DeleteCompositionAsync(cmd.CompositionId, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
// ========================================================================
|
||
// Shared Script handlers
|
||
// ========================================================================
|
||
|
||
private static async Task<object?> HandleListSharedScripts(IServiceProvider sp)
|
||
{
|
||
var svc = sp.GetRequiredService<SharedScriptService>();
|
||
return await svc.GetAllSharedScriptsAsync();
|
||
}
|
||
|
||
private static async Task<object?> HandleGetSharedScript(IServiceProvider sp, GetSharedScriptCommand cmd)
|
||
{
|
||
var svc = sp.GetRequiredService<SharedScriptService>();
|
||
return await svc.GetSharedScriptByIdAsync(cmd.SharedScriptId);
|
||
}
|
||
|
||
private static async Task<object?> HandleCreateSharedScript(IServiceProvider sp, CreateSharedScriptCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<SharedScriptService>();
|
||
var result = await svc.CreateSharedScriptAsync(cmd.Name, cmd.Code, cmd.ParameterDefinitions, cmd.ReturnDefinition, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleUpdateSharedScript(IServiceProvider sp, UpdateSharedScriptCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<SharedScriptService>();
|
||
var result = await svc.UpdateSharedScriptAsync(cmd.SharedScriptId, cmd.Code, cmd.ParameterDefinitions, cmd.ReturnDefinition, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteSharedScript(IServiceProvider sp, DeleteSharedScriptCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<SharedScriptService>();
|
||
var result = await svc.DeleteSharedScriptAsync(cmd.SharedScriptId, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
// ========================================================================
|
||
// Schema Library handlers
|
||
//
|
||
// Reusable named JSON-Schema library (SharedSchema + ISharedSchemaRepository).
|
||
// Repo-backed, mirroring the DatabaseConnection handler idiom: direct
|
||
// repository writes plus an explicit AuditAsync after each mutation. The unique
|
||
// Name is enforced with a clear duplicate-name error and the SchemaJson is
|
||
// validated as a parseable JSON Schema (reusing InboundApiSchema.Parse) so a
|
||
// malformed library entry never reaches the $ref resolver or the
|
||
// schema-driven value-entry forms.
|
||
// ========================================================================
|
||
|
||
private static async Task<object?> HandleListSharedSchemas(IServiceProvider sp)
|
||
{
|
||
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
|
||
return await repo.ListAsync();
|
||
}
|
||
|
||
private static async Task<object?> HandleGetSharedSchema(IServiceProvider sp, GetSharedSchemaCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
|
||
return await repo.GetByIdAsync(cmd.SharedSchemaId);
|
||
}
|
||
|
||
private static async Task<object?> HandleCreateSharedSchema(IServiceProvider sp, CreateSharedSchemaCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
|
||
var name = ValidateSchemaName(cmd.Name);
|
||
ValidateSchemaJson(cmd.SchemaJson);
|
||
|
||
var existing = await repo.GetByNameAsync(name);
|
||
if (existing is not null)
|
||
{
|
||
throw new ManagementCommandException($"A schema named '{name}' already exists.");
|
||
}
|
||
|
||
var schema = new SharedSchema { Name = name, Scope = cmd.Scope, SchemaJson = cmd.SchemaJson };
|
||
await repo.AddAsync(schema);
|
||
await AuditAsync(sp, user, "Create", "SharedSchema", schema.Id.ToString(), schema.Name, new { schema.Id, schema.Name, schema.Scope });
|
||
return schema;
|
||
}
|
||
|
||
private static async Task<object?> HandleUpdateSharedSchema(IServiceProvider sp, UpdateSharedSchemaCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
|
||
var name = ValidateSchemaName(cmd.Name);
|
||
ValidateSchemaJson(cmd.SchemaJson);
|
||
|
||
var schema = await repo.GetByIdAsync(cmd.SharedSchemaId)
|
||
?? throw new ManagementCommandException($"Schema with ID {cmd.SharedSchemaId} not found.");
|
||
|
||
// A rename may not collide with a DIFFERENT row's name. The same name on the
|
||
// same row is allowed (a schema-body-only edit keeps its name).
|
||
var byName = await repo.GetByNameAsync(name);
|
||
if (byName is not null && byName.Id != schema.Id)
|
||
{
|
||
throw new ManagementCommandException($"A schema named '{name}' already exists.");
|
||
}
|
||
|
||
schema.Name = name;
|
||
schema.Scope = cmd.Scope;
|
||
schema.SchemaJson = cmd.SchemaJson;
|
||
await repo.UpdateAsync(schema);
|
||
await AuditAsync(sp, user, "Update", "SharedSchema", schema.Id.ToString(), schema.Name, new { schema.Id, schema.Name, schema.Scope });
|
||
return schema;
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteSharedSchema(IServiceProvider sp, DeleteSharedSchemaCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISharedSchemaRepository>();
|
||
// Pre-fetch the human-readable name before the row is gone so the audit
|
||
// EntityName records "Address" rather than the numeric id — mirroring the
|
||
// Site delete handler. Falls back to the id when the row is already absent.
|
||
var schema = await repo.GetByIdAsync(cmd.SharedSchemaId);
|
||
await repo.DeleteAsync(cmd.SharedSchemaId);
|
||
await AuditAsync(sp, user, "Delete", "SharedSchema", cmd.SharedSchemaId.ToString(),
|
||
schema?.Name ?? cmd.SharedSchemaId.ToString(), null);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates and trims a library schema name. A non-empty unique name is the
|
||
/// lookup key for the <c>lib:Name</c> <c>$ref</c> resolver, so an empty name is a
|
||
/// caller-safe validation error (surfaced verbatim via
|
||
/// <see cref="ManagementCommandException"/>).
|
||
/// </summary>
|
||
private static string ValidateSchemaName(string? rawName)
|
||
{
|
||
var name = rawName?.Trim();
|
||
if (string.IsNullOrEmpty(name))
|
||
{
|
||
throw new ManagementCommandException("Schema name is required.");
|
||
}
|
||
|
||
return name;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates that <paramref name="schemaJson"/> parses as a JSON Schema, reusing the
|
||
/// authoritative <see cref="InboundApiSchema.ParseWithRefs"/> parser so a library
|
||
/// entry obeys the same STRUCTURAL rules as every other stored schema (object form or
|
||
/// legacy flat-array; over-depth and root-scalar inputs rejected). A structural parse
|
||
/// failure is re-thrown as a caller-safe <see cref="ManagementCommandException"/>
|
||
/// naming the fault.
|
||
/// <para>
|
||
/// The <c>ParseWithRefs</c> (collecting) variant is used deliberately so a library
|
||
/// entry that itself carries a <c>{"$ref":"lib:Other"}</c> pointer is NOT rejected at
|
||
/// save time merely because the resolver is not consulted here — a dangling/cyclic
|
||
/// ref surfaces later on the deploy-time / runtime resolution path, exactly
|
||
/// like a forward reference to a not-yet-created entry. Only malformed JSON is a
|
||
/// save-blocking error.
|
||
/// </para>
|
||
/// </summary>
|
||
private static void ValidateSchemaJson(string? schemaJson)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(schemaJson))
|
||
{
|
||
throw new ManagementCommandException("Schema JSON is required.");
|
||
}
|
||
|
||
try
|
||
{
|
||
_ = InboundApiSchema.ParseWithRefs(schemaJson, resolveRef: null);
|
||
}
|
||
catch (JsonException ex)
|
||
{
|
||
throw new ManagementCommandException($"Schema JSON is not a valid JSON Schema: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// ========================================================================
|
||
// Database Connection Definition handlers
|
||
// ========================================================================
|
||
|
||
/// <summary>
|
||
/// Secret-elided DatabaseConnectionDefinition projection (arch-review C3): the
|
||
/// ADO.NET <c>ConnectionString</c> can embed the SQL password and must never
|
||
/// leave this boundary via List/Get responses. Presence is surfaced as
|
||
/// <c>HasConnectionString</c>. Mirrors <see cref="SmtpConfigPublicShape"/>.
|
||
/// </summary>
|
||
private static object DatabaseConnectionPublicShape(DatabaseConnectionDefinition d) => new
|
||
{
|
||
d.Id,
|
||
d.Name,
|
||
HasConnectionString = !string.IsNullOrEmpty(d.ConnectionString),
|
||
};
|
||
|
||
private static async Task<object?> HandleListDatabaseConnections(IServiceProvider sp)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
return (await repo.GetAllDatabaseConnectionsAsync()).Select(DatabaseConnectionPublicShape).ToList();
|
||
}
|
||
|
||
private static async Task<object?> HandleGetDatabaseConnection(IServiceProvider sp, GetDatabaseConnectionCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
var def = await repo.GetDatabaseConnectionByIdAsync(cmd.DatabaseConnectionId);
|
||
return def is null ? null : DatabaseConnectionPublicShape(def);
|
||
}
|
||
|
||
private static async Task<object?> HandleCreateDatabaseConnection(IServiceProvider sp, CreateDatabaseConnectionDefCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
var def = new DatabaseConnectionDefinition(cmd.Name, cmd.ConnectionString);
|
||
await repo.AddDatabaseConnectionAsync(def);
|
||
await repo.SaveChangesAsync();
|
||
await AuditAsync(sp, user, "Create", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
|
||
return DatabaseConnectionPublicShape(def);
|
||
}
|
||
|
||
private static async Task<object?> HandleUpdateDatabaseConnection(IServiceProvider sp, UpdateDatabaseConnectionDefCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
var def = await repo.GetDatabaseConnectionByIdAsync(cmd.DatabaseConnectionId)
|
||
?? throw new ManagementCommandException($"DatabaseConnection with ID {cmd.DatabaseConnectionId} not found.");
|
||
def.Name = cmd.Name;
|
||
// Preserve-if-null: an update that omits ConnectionString leaves the stored
|
||
// secret intact — the connection string is elided from List/Get, so a
|
||
// round-trip must not be required to re-send it.
|
||
if (cmd.ConnectionString is not null) def.ConnectionString = cmd.ConnectionString;
|
||
await repo.UpdateDatabaseConnectionAsync(def);
|
||
await repo.SaveChangesAsync();
|
||
await AuditAsync(sp, user, "Update", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
|
||
return DatabaseConnectionPublicShape(def);
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteDatabaseConnection(IServiceProvider sp, DeleteDatabaseConnectionDefCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
await repo.DeleteDatabaseConnectionAsync(cmd.DatabaseConnectionId);
|
||
await repo.SaveChangesAsync();
|
||
await AuditAsync(sp, user, "Delete", "DatabaseConnection", cmd.DatabaseConnectionId.ToString(), cmd.DatabaseConnectionId.ToString(), null);
|
||
return true;
|
||
}
|
||
|
||
// ========================================================================
|
||
// Inbound API Method handlers
|
||
// ========================================================================
|
||
|
||
private static async Task<object?> HandleListApiMethods(IServiceProvider sp)
|
||
{
|
||
var repo = sp.GetRequiredService<IInboundApiRepository>();
|
||
return await repo.GetAllApiMethodsAsync();
|
||
}
|
||
|
||
private static async Task<object?> HandleGetApiMethod(IServiceProvider sp, GetApiMethodCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<IInboundApiRepository>();
|
||
return await repo.GetApiMethodByIdAsync(cmd.ApiMethodId);
|
||
}
|
||
|
||
private static async Task<object?> HandleCreateApiMethod(IServiceProvider sp, CreateApiMethodCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<IInboundApiRepository>();
|
||
var method = new ApiMethod(cmd.Name, cmd.Script)
|
||
{
|
||
TimeoutSeconds = cmd.TimeoutSeconds,
|
||
ParameterDefinitions = cmd.ParameterDefinitions,
|
||
ReturnDefinition = cmd.ReturnDefinition,
|
||
TraceAllCalls = cmd.TraceAllCalls
|
||
};
|
||
await repo.AddApiMethodAsync(method);
|
||
await repo.SaveChangesAsync();
|
||
// The script is persisted regardless; surface a non-fatal warning when it does
|
||
// not compile so the caller is not misled into thinking a broken method is live
|
||
// (a never-compiled method returns "Script compilation failed" on first call).
|
||
var compileWarning = TryCompileAndDescribe(sp, method, isUpdate: false);
|
||
await AuditAsync(sp, user, "Create", "ApiMethod", method.Id.ToString(), method.Name, method);
|
||
return ApiMethodResult(method, compileWarning);
|
||
}
|
||
|
||
private static async Task<object?> HandleUpdateApiMethod(IServiceProvider sp, UpdateApiMethodCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<IInboundApiRepository>();
|
||
var method = await repo.GetApiMethodByIdAsync(cmd.ApiMethodId)
|
||
?? throw new ManagementCommandException($"ApiMethod with ID {cmd.ApiMethodId} not found.");
|
||
method.Script = cmd.Script;
|
||
method.TimeoutSeconds = cmd.TimeoutSeconds;
|
||
method.ParameterDefinitions = cmd.ParameterDefinitions;
|
||
method.ReturnDefinition = cmd.ReturnDefinition;
|
||
if (cmd.TraceAllCalls is { } traceAllCalls)
|
||
method.TraceAllCalls = traceAllCalls;
|
||
await repo.UpdateApiMethodAsync(method);
|
||
await repo.SaveChangesAsync();
|
||
// The persisted DB row is authoritative: on a compile failure the method returns
|
||
// a compilation error on every request (ExecuteAsync revision-checks the cached
|
||
// handler against the fresh row), so the saved-but-broken script does NOT keep a
|
||
// working version serving. Surface that as a warning rather than a silent success
|
||
// (the change still persists either way).
|
||
var compileWarning = TryCompileAndDescribe(sp, method, isUpdate: true);
|
||
await AuditAsync(sp, user, "Update", "ApiMethod", method.Id.ToString(), method.Name, method);
|
||
return ApiMethodResult(method, compileWarning);
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteApiMethod(IServiceProvider sp, DeleteApiMethodCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<IInboundApiRepository>();
|
||
var method = await repo.GetApiMethodByIdAsync(cmd.ApiMethodId);
|
||
await repo.DeleteApiMethodAsync(cmd.ApiMethodId);
|
||
await repo.SaveChangesAsync();
|
||
if (method != null)
|
||
sp.GetService<InboundAPI.InboundScriptExecutor>()?.InvalidateMethod(method.Name);
|
||
await AuditAsync(sp, user, "Delete", "ApiMethod", cmd.ApiMethodId.ToString(), method?.Name ?? cmd.ApiMethodId.ToString(), null);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Recompiles and re-registers an inbound API method's script after a Create/Update,
|
||
/// returning a non-null human-readable warning when the script did NOT compile. The
|
||
/// save is always persisted; the persisted DB row is authoritative, so on compile
|
||
/// failure the method returns a compilation error on every request (ExecuteAsync
|
||
/// revision-checks the cached handler against the fresh row) rather than a previously
|
||
/// registered handler silently continuing to serve (see
|
||
/// <c>InboundScriptExecutor.CompileAndRegister</c>). Returns <c>null</c> when the
|
||
/// script compiled, or when no executor is registered (e.g. a non-inbound host or a
|
||
/// test double).
|
||
/// </summary>
|
||
private static string? TryCompileAndDescribe(IServiceProvider sp, ApiMethod method, bool isUpdate)
|
||
{
|
||
var executor = sp.GetService<InboundAPI.InboundScriptExecutor>();
|
||
if (executor is null)
|
||
return null;
|
||
if (executor.CompileAndRegister(method, out var errors))
|
||
return null;
|
||
|
||
var detail = string.Join("; ", errors);
|
||
return isUpdate
|
||
? $"Script saved but failed to compile; the method will return a compilation error on every request until a compiling version is saved. Compilation errors: {detail}"
|
||
: $"Script saved but failed to compile; the method will return a compilation error until a compiling version is saved. Compilation errors: {detail}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Projects an <see cref="ApiMethod"/> to the Create/Update response shape, adding a
|
||
/// top-level <c>compileWarning</c> (null when the script compiled). Kept as a flat
|
||
/// projection so the method fields stay top-level for the CLI/Central UI and the
|
||
/// persistence-ignorant POCO entity is not polluted with a transient management field.
|
||
/// </summary>
|
||
private static object ApiMethodResult(ApiMethod method, string? compileWarning) => new
|
||
{
|
||
method.Id,
|
||
method.Name,
|
||
method.Script,
|
||
method.ParameterDefinitions,
|
||
method.ReturnDefinition,
|
||
method.TimeoutSeconds,
|
||
method.TraceAllCalls,
|
||
compileWarning,
|
||
};
|
||
|
||
// ========================================================================
|
||
// Additional Security handlers (API key update, scope rules)
|
||
// ========================================================================
|
||
|
||
private static async Task<object?> HandleUpdateApiKey(IServiceProvider sp, UpdateApiKeyCommand cmd, string user)
|
||
{
|
||
// Inbound-API key re-arch: enable/disable via the shared seam (no secret change).
|
||
var admin = sp.GetRequiredService<IInboundApiKeyAdmin>();
|
||
var updated = await admin.SetEnabledAsync(cmd.KeyId, cmd.IsEnabled);
|
||
if (!updated)
|
||
throw new ManagementCommandException($"API key '{cmd.KeyId}' not found.");
|
||
await AuditAsync(sp, user, "Update", "ApiKey", cmd.KeyId, cmd.KeyId,
|
||
new { cmd.KeyId, cmd.IsEnabled });
|
||
return new { cmd.KeyId, cmd.IsEnabled };
|
||
}
|
||
|
||
private static async Task<object?> HandleSetApiKeyMethods(IServiceProvider sp, SetApiKeyMethodsCommand cmd, string user)
|
||
{
|
||
// Inbound-API key re-arch: replace a key's method-scope set via the shared seam
|
||
// (no secret change). The library is authoritative for the scope replacement.
|
||
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<IInboundApiKeyAdmin>();
|
||
var updated = await admin.SetMethodsAsync(cmd.KeyId, cmd.Methods);
|
||
if (!updated)
|
||
throw new ManagementCommandException($"API key '{cmd.KeyId}' not found.");
|
||
await AuditAsync(sp, user, "Update", "ApiKey", cmd.KeyId, cmd.KeyId,
|
||
new { cmd.KeyId, cmd.Methods });
|
||
return new { cmd.KeyId, Methods = cmd.Methods };
|
||
}
|
||
|
||
private static async Task<object?> HandleListScopeRules(IServiceProvider sp, ListScopeRulesCommand cmd)
|
||
{
|
||
var repo = sp.GetRequiredService<ISecurityRepository>();
|
||
return await repo.GetScopeRulesForMappingAsync(cmd.MappingId);
|
||
}
|
||
|
||
private static async Task<object?> HandleAddScopeRule(IServiceProvider sp, AddScopeRuleCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISecurityRepository>();
|
||
var rule = new SiteScopeRule { LdapGroupMappingId = cmd.MappingId, SiteId = cmd.SiteId };
|
||
await repo.AddScopeRuleAsync(rule);
|
||
await repo.SaveChangesAsync();
|
||
await AuditAsync(sp, user, "Create", "ScopeRule", rule.Id.ToString(), $"Mapping:{cmd.MappingId}/Site:{cmd.SiteId}", rule);
|
||
return rule;
|
||
}
|
||
|
||
private static async Task<object?> HandleDeleteScopeRule(IServiceProvider sp, DeleteScopeRuleCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ISecurityRepository>();
|
||
await repo.DeleteScopeRuleAsync(cmd.ScopeRuleId);
|
||
await repo.SaveChangesAsync();
|
||
await AuditAsync(sp, user, "Delete", "ScopeRule", cmd.ScopeRuleId.ToString(), cmd.ScopeRuleId.ToString(), null);
|
||
return true;
|
||
}
|
||
|
||
// ========================================================================
|
||
// Area update handler
|
||
// ========================================================================
|
||
|
||
private static async Task<object?> HandleUpdateArea(IServiceProvider sp, UpdateAreaCommand cmd, string user)
|
||
{
|
||
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
var area = await repo.GetAreaByIdAsync(cmd.AreaId)
|
||
?? throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found.");
|
||
area.Name = cmd.Name;
|
||
await repo.UpdateAreaAsync(area);
|
||
await repo.SaveChangesAsync();
|
||
await AuditAsync(sp, user, "Update", "Area", area.Id.ToString(), area.Name, area);
|
||
return area;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Re-parents an area within its site, delegating every validation rule
|
||
/// (not-found, self-parent, descendant-parent cycle, cross-site parent,
|
||
/// sibling name collision) to <c>AreaService.MoveAreaAsync</c>. A null
|
||
/// <c>NewParentAreaId</c> moves the area to the site root. Failures surface
|
||
/// as the standard curated <see cref="ManagementCommandException"/> failure
|
||
/// response, matching the sibling folder/template move handlers. The service
|
||
/// writes the "Move" audit row itself, so this handler does not audit again.
|
||
/// </summary>
|
||
private static async Task<object?> HandleMoveArea(IServiceProvider sp, MoveAreaCommand cmd, string user)
|
||
{
|
||
var svc = sp.GetRequiredService<AreaService>();
|
||
var result = await svc.MoveAreaAsync(cmd.AreaId, cmd.NewParentAreaId, user);
|
||
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
|
||
}
|
||
|
||
// ========================================================================
|
||
// Remote Query handlers
|
||
// ========================================================================
|
||
|
||
private static async Task<object?> HandleQueryEventLogs(IServiceProvider sp, QueryEventLogsCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);
|
||
var commService = sp.GetRequiredService<CommunicationService>();
|
||
var request = new EventLogQueryRequest(
|
||
Guid.NewGuid().ToString("N"),
|
||
cmd.SiteIdentifier,
|
||
cmd.From, cmd.To,
|
||
cmd.EventType, cmd.Severity,
|
||
// Instance unique-name filter — the site event log's instance_id column
|
||
// stores the instance UniqueName (InstanceActor.LogLifecycleEvent passes
|
||
// _instanceUniqueName; EventLogQueryService matches instance_id = $instanceId).
|
||
cmd.InstanceName,
|
||
cmd.Keyword,
|
||
null, // ContinuationToken
|
||
cmd.PageSize,
|
||
DateTimeOffset.UtcNow);
|
||
return await commService.QueryEventLogsAsync(cmd.SiteIdentifier, request);
|
||
}
|
||
|
||
// OPC UA / MxGateway design-time remote queries (arch-review C4). These
|
||
// handlers give the management API/CLI parity with the Central UI, which
|
||
// already reaches these site actors directly. SiteIdentifier is REQUIRED —
|
||
// the actor must know which site to route to — and site-scope is enforced
|
||
// before relaying, exactly like HandleQueryEventLogs.
|
||
|
||
private static async Task<object?> HandleBrowseNode(IServiceProvider sp, BrowseNodeCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var site = cmd.SiteIdentifier
|
||
?? throw new ManagementCommandException("SiteIdentifier is required when browsing an address space via the management API.");
|
||
await EnforceSiteScopeForIdentifier(sp, user, site);
|
||
var commService = sp.GetRequiredService<CommunicationService>();
|
||
return await commService.BrowseNodeAsync(site, cmd);
|
||
}
|
||
|
||
private static async Task<object?> HandleSearchAddressSpace(IServiceProvider sp, SearchAddressSpaceCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var site = cmd.SiteIdentifier
|
||
?? throw new ManagementCommandException("SiteIdentifier is required when searching an address space via the management API.");
|
||
await EnforceSiteScopeForIdentifier(sp, user, site);
|
||
var commService = sp.GetRequiredService<CommunicationService>();
|
||
return await commService.SearchAddressSpaceAsync(site, cmd);
|
||
}
|
||
|
||
private static async Task<object?> HandleVerifyEndpoint(IServiceProvider sp, VerifyEndpointCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var site = cmd.SiteIdentifier
|
||
?? throw new ManagementCommandException("SiteIdentifier is required when verifying an endpoint via the management API.");
|
||
await EnforceSiteScopeForIdentifier(sp, user, site);
|
||
var commService = sp.GetRequiredService<CommunicationService>();
|
||
return await commService.VerifyEndpointAsync(site, cmd);
|
||
}
|
||
|
||
// Server-certificate trust (arch-review C4). Admin-gated at the role check
|
||
// above. These mutate the site's trusted-peer PKI store — the handler only
|
||
// relays; the site-side broadcast/reconcile across both nodes is owned by
|
||
// the site CertStoreActor (plan 03). SiteIdentifier is REQUIRED so the actor
|
||
// knows which site to route to.
|
||
|
||
private static async Task<object?> HandleTrustServerCert(IServiceProvider sp, TrustServerCertCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var site = cmd.SiteIdentifier
|
||
?? throw new ManagementCommandException("SiteIdentifier is required when trusting a server certificate via the management API.");
|
||
await EnforceSiteScopeForIdentifier(sp, user, site);
|
||
var commService = sp.GetRequiredService<CommunicationService>();
|
||
return await commService.TrustServerCertAsync(site, cmd);
|
||
}
|
||
|
||
private static async Task<object?> HandleRemoveServerCert(IServiceProvider sp, RemoveServerCertCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var site = cmd.SiteIdentifier
|
||
?? throw new ManagementCommandException("SiteIdentifier is required when removing a server certificate via the management API.");
|
||
await EnforceSiteScopeForIdentifier(sp, user, site);
|
||
var commService = sp.GetRequiredService<CommunicationService>();
|
||
return await commService.RemoveServerCertAsync(site, cmd);
|
||
}
|
||
|
||
private static async Task<object?> HandleListServerCerts(IServiceProvider sp, ListServerCertsCommand cmd, AuthenticatedUser user)
|
||
{
|
||
var site = cmd.SiteIdentifier
|
||
?? throw new ManagementCommandException("SiteIdentifier is required when listing server certificates via the management API.");
|
||
await EnforceSiteScopeForIdentifier(sp, user, site);
|
||
var commService = sp.GetRequiredService<CommunicationService>();
|
||
return await commService.ListServerCertsAsync(site, cmd);
|
||
}
|
||
|
||
private static async Task<object?> HandleQueryParkedMessages(IServiceProvider sp, QueryParkedMessagesCommand cmd, AuthenticatedUser user)
|
||
{
|
||
await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);
|
||
var commService = sp.GetRequiredService<CommunicationService>();
|
||
var request = new ParkedMessageQueryRequest(
|
||
Guid.NewGuid().ToString("N"),
|
||
cmd.SiteIdentifier,
|
||
cmd.Page,
|
||
cmd.PageSize,
|
||
DateTimeOffset.UtcNow);
|
||
return await commService.QueryParkedMessagesAsync(cmd.SiteIdentifier, request);
|
||
}
|
||
|
||
private static async Task<object?> HandleDebugSnapshot(IServiceProvider sp, DebugSnapshotCommand cmd, AuthenticatedUser user)
|
||
{
|
||
// Fail-CLOSED before the not-found (arch-review 2026-08-19 P6-5): reporting
|
||
// "Instance {id} not found" ahead of the scope check made this handler an
|
||
// instance-id existence oracle for an out-of-scope caller. The scope denial is
|
||
// now the same answer whether or not the instance exists.
|
||
var instanceRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
var instance = await instanceRepo.GetInstanceByIdAsync(cmd.InstanceId);
|
||
if (instance is null)
|
||
{
|
||
DenyUnresolvedTargetIfScoped(user, $"instance {cmd.InstanceId}");
|
||
throw new ManagementCommandException($"Instance {cmd.InstanceId} not found.");
|
||
}
|
||
|
||
EnforceSiteScope(user, instance.SiteId);
|
||
|
||
var siteRepo = sp.GetRequiredService<ISiteRepository>();
|
||
var site = await siteRepo.GetSiteByIdAsync(instance.SiteId)
|
||
?? throw new ManagementCommandException($"Site {instance.SiteId} not found.");
|
||
|
||
var commService = sp.GetRequiredService<CommunicationService>();
|
||
var request = new DebugSnapshotRequest(instance.UniqueName, Guid.NewGuid().ToString("N"));
|
||
return await commService.RequestDebugSnapshotAsync(site.SiteIdentifier, request);
|
||
}
|
||
|
||
// ========================================================================
|
||
// Transport bundle operations
|
||
// ========================================================================
|
||
|
||
/// <summary>Single-flight gate for transport bundle commands: a 200 MB import holds
|
||
/// ~4 concurrent whole-artifact copies (base64 string, byte[], MemoryStream, JSON envelope),
|
||
/// so two concurrent imports on one central node are a realistic OOM path (arch-review S4).
|
||
/// Commands queue here instead of running concurrently on the thread pool. Note the actor
|
||
/// pipes these handlers to the sender (HandleEnvelope), so without this gate two envelopes
|
||
/// received back-to-back run their handlers concurrently.</summary>
|
||
private static readonly SemaphoreSlim TransportGate = new(1, 1);
|
||
|
||
/// <summary>
|
||
/// Resolves the per-type name lists in <paramref name="cmd"/> against the
|
||
/// repositories, builds an <see cref="ExportSelection"/>, exports the bundle,
|
||
/// and returns the encrypted ZIP as base64.
|
||
/// </summary>
|
||
private static async Task<object?> HandleExportBundle(
|
||
IServiceProvider sp, ExportBundleCommand cmd, string username)
|
||
{
|
||
await TransportGate.WaitAsync();
|
||
try
|
||
{
|
||
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||
var externalRepo = sp.GetRequiredService<IExternalSystemRepository>();
|
||
var notifRepo = sp.GetRequiredService<INotificationRepository>();
|
||
var inboundRepo = sp.GetRequiredService<IInboundApiRepository>();
|
||
var siteRepo = sp.GetRequiredService<ISiteRepository>();
|
||
|
||
var templates = await templateRepo.GetAllTemplatesAsync();
|
||
var sharedScripts = await templateRepo.GetAllSharedScriptsAsync();
|
||
var externalSystems = await externalRepo.GetAllExternalSystemsAsync();
|
||
var dbConnections = await externalRepo.GetAllDatabaseConnectionsAsync();
|
||
var notificationLists = await notifRepo.GetAllNotificationListsAsync();
|
||
var smtpConfigs = await notifRepo.GetAllSmtpConfigurationsAsync();
|
||
// SMS: central-only SMS provider configs, mirroring smtpConfigs.
|
||
var smsConfigs = await notifRepo.GetAllSmsConfigurationsAsync();
|
||
// Inbound API keys are not transported between environments; only methods.
|
||
var apiMethods = await inboundRepo.GetAllApiMethodsAsync();
|
||
// Site/instance-scoped selection. Sites match on SiteIdentifier
|
||
// (preferred) or Name; instances match on UniqueName.
|
||
var sites = await siteRepo.GetAllSitesAsync();
|
||
var instances = await templateRepo.GetAllInstancesAsync();
|
||
|
||
int[] ResolveIds<T>(IReadOnlyList<T> all, IReadOnlyList<string>? names,
|
||
Func<T, string> getName, Func<T, int> getId, string entityType)
|
||
{
|
||
if (cmd.All) return all.Select(getId).ToArray();
|
||
if (names is null || names.Count == 0) return Array.Empty<int>();
|
||
var nameSet = new HashSet<string>(names, StringComparer.Ordinal);
|
||
var matched = all.Where(e => nameSet.Contains(getName(e))).Select(getId).ToArray();
|
||
var missing = nameSet
|
||
.Except(all.Select(getName), StringComparer.Ordinal)
|
||
.ToArray();
|
||
if (missing.Length > 0)
|
||
{
|
||
throw new ManagementCommandException(
|
||
$"Unknown {entityType} name(s): {string.Join(", ", missing.OrderBy(n => n, StringComparer.Ordinal))}.");
|
||
}
|
||
return matched;
|
||
}
|
||
|
||
// Sites accept EITHER the SiteIdentifier (preferred) or the friendly Name
|
||
// per requested token, so this needs a two-key matcher rather than the
|
||
// single-key ResolveIds above. Under All=true every site is included.
|
||
int[] ResolveSiteIds()
|
||
{
|
||
if (cmd.All) return sites.Select(s => s.Id).ToArray();
|
||
var names = cmd.SiteNames;
|
||
if (names is null || names.Count == 0) return Array.Empty<int>();
|
||
var matchedIds = new List<int>();
|
||
var unmatched = new List<string>();
|
||
foreach (var token in names)
|
||
{
|
||
var site = sites.FirstOrDefault(s =>
|
||
string.Equals(s.SiteIdentifier, token, StringComparison.Ordinal))
|
||
?? sites.FirstOrDefault(s =>
|
||
string.Equals(s.Name, token, StringComparison.Ordinal));
|
||
if (site is null) unmatched.Add(token);
|
||
else matchedIds.Add(site.Id);
|
||
}
|
||
if (unmatched.Count > 0)
|
||
{
|
||
throw new ManagementCommandException(
|
||
$"Unknown site identifier/name(s): {string.Join(", ", unmatched.OrderBy(n => n, StringComparer.Ordinal))}.");
|
||
}
|
||
return matchedIds.Distinct().ToArray();
|
||
}
|
||
|
||
var selection = new ExportSelection(
|
||
TemplateIds: ResolveIds(templates, cmd.TemplateNames, t => t.Name, t => t.Id, "template"),
|
||
SharedScriptIds: ResolveIds(sharedScripts, cmd.SharedScriptNames, s => s.Name, s => s.Id, "shared script"),
|
||
ExternalSystemIds: ResolveIds(externalSystems, cmd.ExternalSystemNames, e => e.Name, e => e.Id, "external system"),
|
||
DatabaseConnectionIds: ResolveIds(dbConnections, cmd.DatabaseConnectionNames, d => d.Name, d => d.Id, "database connection"),
|
||
NotificationListIds: ResolveIds(notificationLists, cmd.NotificationListNames, n => n.Name, n => n.Id, "notification list"),
|
||
// SmtpConfiguration is keyed by Host (no Name column); the bundle
|
||
// preview row shows the Host value, so the CLI uses Host too.
|
||
SmtpConfigurationIds: ResolveIds(smtpConfigs, cmd.SmtpConfigurationNames, s => s.Host, s => s.Id, "SMTP configuration"),
|
||
ApiMethodIds: ResolveIds(apiMethods, cmd.ApiMethodNames, m => m.Name, m => m.Id, "API method"),
|
||
IncludeDependencies: cmd.IncludeDependencies,
|
||
// Site/instance-scoped selection. Under All=true include every
|
||
// site + instance, mirroring how All includes every template/etc.
|
||
SiteIds: ResolveSiteIds(),
|
||
InstanceIds: ResolveIds(instances, cmd.InstanceNames, i => i.UniqueName, i => i.Id, "instance"),
|
||
// SMS: SmsConfiguration is keyed by AccountSid (no Name column);
|
||
// the bundle preview row shows AccountSid, so the CLI uses AccountSid too.
|
||
SmsConfigurationIds: ResolveIds(smsConfigs, cmd.SmsConfigurationNames, s => s.AccountSid, s => s.Id, "SMS configuration"));
|
||
|
||
var exporter = sp.GetRequiredService<IBundleExporter>();
|
||
await using var stream = await exporter.ExportAsync(
|
||
selection, user: username, sourceEnvironment: cmd.SourceEnvironment,
|
||
passphrase: cmd.Passphrase);
|
||
|
||
using var ms = new MemoryStream();
|
||
await stream.CopyToAsync(ms);
|
||
// Encode directly off the MemoryStream's backing buffer rather than
|
||
// ms.ToArray() — the latter allocates a second whole-artifact copy.
|
||
// The long-term shape is streaming multipart to the caller (as the
|
||
// audit export path does, AuditEndpoints.cs:183+); deferred for now.
|
||
var base64 = Convert.ToBase64String(ms.GetBuffer().AsSpan(0, (int)ms.Length));
|
||
return new ExportBundleResult(base64, (int)ms.Length);
|
||
}
|
||
finally
|
||
{
|
||
TransportGate.Release();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Loads + diffs a base64-encoded bundle and returns the preview rows. Does
|
||
/// not apply anything; callers can use this to gate Apply on the diff shape.
|
||
/// </summary>
|
||
private static async Task<object?> HandlePreviewBundle(
|
||
IServiceProvider sp, PreviewBundleCommand cmd, string? clientIp)
|
||
{
|
||
await TransportGate.WaitAsync();
|
||
try
|
||
{
|
||
var importer = sp.GetRequiredService<IBundleImporter>();
|
||
var bytes = DecodeBundle(cmd.Base64Bundle);
|
||
BundleSession session;
|
||
try
|
||
{
|
||
await using var stream = new MemoryStream(bytes);
|
||
session = await importer.LoadAsync(stream, cmd.Passphrase, clientKey: clientIp);
|
||
}
|
||
catch (ArgumentException ex)
|
||
{
|
||
throw new ManagementCommandException(ex.Message);
|
||
}
|
||
catch (BundleLockedException ex)
|
||
{
|
||
// Register row 51: the per-bundle 3-strike lockout. LoadAsync raises
|
||
// it from inside its own CryptographicException handler, so without
|
||
// this arm the operator saw MapFault's generic internal-error message
|
||
// plus a correlation id, never the reason. Listed ahead of the
|
||
// CryptographicException arm to keep the read order matching the
|
||
// importer's throw order (the two types are unrelated, so the
|
||
// ordering is documentation, not dispatch).
|
||
throw new ManagementCommandException(ex.Message);
|
||
}
|
||
catch (System.Security.Cryptography.CryptographicException)
|
||
{
|
||
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
|
||
}
|
||
catch (BundleUnlockThrottledException ex)
|
||
{
|
||
// Register row 44: per-IP-per-hour unlock cap. Curated so the CLI
|
||
// prints the reason instead of MapFault's generic internal-error
|
||
// message; no per-bundle strike was burned.
|
||
throw new ManagementCommandException(ex.Message);
|
||
}
|
||
|
||
// Register row 43: a preview NEVER applies, so nothing else would ever
|
||
// release this session — every `transport preview` used to burn one of
|
||
// MaxConcurrentImportSessions (and hold the decrypted bundle) for the
|
||
// full BundleSessionTtlMinutes. Release it on the way out, always.
|
||
try
|
||
{
|
||
var preview = await importer.PreviewAsync(session.SessionId);
|
||
var adds = preview.Items.Count(i => i.Kind == ConflictKind.New);
|
||
var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
|
||
var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
|
||
var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
|
||
// Surface the required site/connection mappings so an operator
|
||
// (CLI or UI) sees which references need resolving before applying.
|
||
return new PreviewBundleResult(
|
||
preview.Items, adds, mods, ids, blocks,
|
||
preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
|
||
}
|
||
finally
|
||
{
|
||
importer.Abandon(session.SessionId);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
TransportGate.Release();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// One-shot import: load + preview + apply with a single global conflict
|
||
/// policy applied to every <see cref="ConflictKind.Modified"/> row. Any
|
||
/// <see cref="ConflictKind.Blocker"/> rows fail the call before Apply.
|
||
/// </summary>
|
||
private static async Task<object?> HandleImportBundle(
|
||
IServiceProvider sp, ImportBundleCommand cmd, string username, string? clientIp)
|
||
{
|
||
await TransportGate.WaitAsync();
|
||
try
|
||
{
|
||
var policy = ParseConflictPolicy(cmd.DefaultConflictPolicy);
|
||
var importer = sp.GetRequiredService<IBundleImporter>();
|
||
var bytes = DecodeBundle(cmd.Base64Bundle);
|
||
|
||
BundleSession session;
|
||
try
|
||
{
|
||
await using var stream = new MemoryStream(bytes);
|
||
session = await importer.LoadAsync(stream, cmd.Passphrase, clientKey: clientIp);
|
||
}
|
||
catch (ArgumentException ex)
|
||
{
|
||
throw new ManagementCommandException(ex.Message);
|
||
}
|
||
catch (BundleLockedException ex)
|
||
{
|
||
// Register row 51: the per-bundle 3-strike lockout. LoadAsync raises
|
||
// it from inside its own CryptographicException handler, so without
|
||
// this arm the operator saw MapFault's generic internal-error message
|
||
// plus a correlation id, never the reason. Listed ahead of the
|
||
// CryptographicException arm to keep the read order matching the
|
||
// importer's throw order (the two types are unrelated, so the
|
||
// ordering is documentation, not dispatch).
|
||
throw new ManagementCommandException(ex.Message);
|
||
}
|
||
catch (System.Security.Cryptography.CryptographicException)
|
||
{
|
||
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
|
||
}
|
||
catch (BundleUnlockThrottledException ex)
|
||
{
|
||
// Register row 44: per-IP-per-hour unlock cap. Curated so the CLI
|
||
// prints the reason instead of MapFault's generic internal-error
|
||
// message; no per-bundle strike was burned.
|
||
throw new ManagementCommandException(ex.Message);
|
||
}
|
||
|
||
// Register row 43: ApplyAsync releases the session on both its success
|
||
// and failure paths, but every abort BEFORE it (blockers, unresolved
|
||
// mappings) used to strand the session — and its decrypted content —
|
||
// for the full TTL. Abandon is idempotent, so covering the whole block
|
||
// is safe even when ApplyAsync already released the same id.
|
||
try
|
||
{
|
||
var preview = await importer.PreviewAsync(session.SessionId);
|
||
|
||
var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList();
|
||
if (blockers.Count > 0)
|
||
{
|
||
var details = string.Join("; ",
|
||
blockers.Select(b => $"{b.Name}: {b.BlockerReason}"));
|
||
throw new ManagementCommandException(
|
||
$"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
|
||
}
|
||
|
||
// Resolve every required site/connection reference into a
|
||
// BundleNameMap. Precedence: an explicit operator spec wins; otherwise
|
||
// fall back to the preview's auto-match; otherwise (no match) create-new
|
||
// ONLY if the create-missing flag is set, else fail with a clear message.
|
||
var nameMap = BuildNameMap(cmd, preview);
|
||
|
||
// Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
|
||
// entity (e.g. one row per modified member of a template), but ApplyAsync
|
||
// requires a unique resolution per key. Last-write-wins matches the
|
||
// Central UI's TransportImport.BuildDefaultResolutions behavior. For
|
||
// DataConnection rows the preview item Name is already site-qualified
|
||
// ({site}/{name}), so keying generically by (EntityType, Name) is
|
||
// automatically correct — no bare-connection-name special case needed.
|
||
var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
|
||
var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
|
||
foreach (var item in preview.Items)
|
||
{
|
||
var action = item.Kind switch
|
||
{
|
||
ConflictKind.New => ResolutionAction.Add,
|
||
ConflictKind.Identical => ResolutionAction.Skip,
|
||
ConflictKind.Modified => policy,
|
||
_ => ResolutionAction.Skip,
|
||
};
|
||
var renameTo = (item.Kind == ConflictKind.Modified && policy == ResolutionAction.Rename)
|
||
? $"{item.Name}-imported-{renameStamp}"
|
||
: null;
|
||
resolutionsMap[(item.EntityType, item.Name)] = new ImportResolution(
|
||
item.EntityType, item.Name, action, renameTo);
|
||
}
|
||
|
||
return await importer.ApplyAsync(
|
||
session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
|
||
}
|
||
finally
|
||
{
|
||
importer.Abandon(session.SessionId);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
TransportGate.Release();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Merges the operator-supplied <see cref="SiteMappingSpec"/> /
|
||
/// <see cref="ConnectionMappingSpec"/> lists with the preview's auto-matches
|
||
/// and the create-missing flags into a <see cref="BundleNameMap"/>.
|
||
/// <para>
|
||
/// Per required site reference: an explicit spec wins (target present →
|
||
/// <see cref="MappingAction.MapToExisting"/>; null/blank target →
|
||
/// <see cref="MappingAction.CreateNew"/>); otherwise the preview's
|
||
/// <see cref="RequiredSiteMapping.AutoMatchTargetIdentifier"/> is used when
|
||
/// present (MapToExisting); otherwise CreateNew only when
|
||
/// <see cref="ImportBundleCommand.CreateMissingSites"/> is set, else the
|
||
/// reference is unresolved and the import fails. Connections mirror this with
|
||
/// <see cref="ImportBundleCommand.CreateMissingConnections"/>.
|
||
/// </para>
|
||
/// </summary>
|
||
private static BundleNameMap BuildNameMap(ImportBundleCommand cmd, ImportPreview preview)
|
||
{
|
||
var siteSpecs = (cmd.SiteMappings ?? Array.Empty<SiteMappingSpec>())
|
||
.ToDictionary(s => s.SourceSiteIdentifier, StringComparer.Ordinal);
|
||
var connSpecs = (cmd.ConnectionMappings ?? Array.Empty<ConnectionMappingSpec>())
|
||
.ToDictionary(
|
||
c => (c.SourceSiteIdentifier, c.SourceConnectionName),
|
||
c => c);
|
||
|
||
var siteMappings = new List<SiteMapping>();
|
||
var unresolved = new List<string>();
|
||
foreach (var required in preview.RequiredSiteMappings)
|
||
{
|
||
if (siteSpecs.TryGetValue(required.SourceSiteIdentifier, out var spec))
|
||
{
|
||
siteMappings.Add(string.IsNullOrWhiteSpace(spec.TargetSiteIdentifier)
|
||
? new SiteMapping(required.SourceSiteIdentifier, MappingAction.CreateNew, null)
|
||
: new SiteMapping(required.SourceSiteIdentifier, MappingAction.MapToExisting, spec.TargetSiteIdentifier));
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(required.AutoMatchTargetIdentifier))
|
||
{
|
||
siteMappings.Add(new SiteMapping(
|
||
required.SourceSiteIdentifier, MappingAction.MapToExisting, required.AutoMatchTargetIdentifier));
|
||
}
|
||
else if (cmd.CreateMissingSites)
|
||
{
|
||
siteMappings.Add(new SiteMapping(
|
||
required.SourceSiteIdentifier, MappingAction.CreateNew, null));
|
||
}
|
||
else
|
||
{
|
||
unresolved.Add($"site '{required.SourceSiteIdentifier}'");
|
||
}
|
||
}
|
||
|
||
var connMappings = new List<ConnectionMapping>();
|
||
foreach (var required in preview.RequiredConnectionMappings)
|
||
{
|
||
var key = (required.SourceSiteIdentifier, required.SourceConnectionName);
|
||
if (connSpecs.TryGetValue(key, out var spec))
|
||
{
|
||
connMappings.Add(string.IsNullOrWhiteSpace(spec.TargetConnectionName)
|
||
? new ConnectionMapping(required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.CreateNew, null)
|
||
: new ConnectionMapping(required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.MapToExisting, spec.TargetConnectionName));
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(required.AutoMatchTargetName))
|
||
{
|
||
connMappings.Add(new ConnectionMapping(
|
||
required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.MapToExisting, required.AutoMatchTargetName));
|
||
}
|
||
else if (cmd.CreateMissingConnections)
|
||
{
|
||
connMappings.Add(new ConnectionMapping(
|
||
required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.CreateNew, null));
|
||
}
|
||
else
|
||
{
|
||
unresolved.Add($"connection '{required.SourceSiteIdentifier}/{required.SourceConnectionName}'");
|
||
}
|
||
}
|
||
|
||
if (unresolved.Count > 0)
|
||
{
|
||
throw new ManagementCommandException(
|
||
$"Import has {unresolved.Count} unresolved mapping(s): " +
|
||
$"{string.Join(", ", unresolved.OrderBy(u => u, StringComparer.Ordinal))}. " +
|
||
"Supply --map-site/--map-connection for each, or pass " +
|
||
"--create-missing-sites/--create-missing-connections to create them from the bundle.");
|
||
}
|
||
|
||
return new BundleNameMap(siteMappings, connMappings);
|
||
}
|
||
|
||
private static byte[] DecodeBundle(string base64)
|
||
{
|
||
if (string.IsNullOrEmpty(base64))
|
||
{
|
||
throw new ManagementCommandException("Bundle payload is empty.");
|
||
}
|
||
try
|
||
{
|
||
return Convert.FromBase64String(base64);
|
||
}
|
||
catch (FormatException)
|
||
{
|
||
throw new ManagementCommandException("Bundle payload is not valid base64.");
|
||
}
|
||
}
|
||
|
||
private static ResolutionAction ParseConflictPolicy(string? raw)
|
||
{
|
||
return (raw ?? string.Empty).Trim().ToLowerInvariant() switch
|
||
{
|
||
"skip" => ResolutionAction.Skip,
|
||
"overwrite" => ResolutionAction.Overwrite,
|
||
"rename" => ResolutionAction.Rename,
|
||
_ => throw new ManagementCommandException(
|
||
$"Invalid DefaultConflictPolicy '{raw}'. Use one of: skip, overwrite, rename."),
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Thrown when a site-scoped user attempts an operation on a site they don't have access to.
|
||
/// </summary>
|
||
public class SiteScopeViolationException : Exception
|
||
{
|
||
/// <summary>Initializes a new instance with the given error message.</summary>
|
||
/// <param name="message">The error message describing the scope violation.</param>
|
||
public SiteScopeViolationException(string message) : base(message) { }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Thrown by a command handler to signal a curated, caller-safe failure. Its
|
||
/// <see cref="Exception.Message"/> is intended to be
|
||
/// surfaced verbatim to the HTTP/CLI caller — e.g. a validation result or a
|
||
/// "not found" message. Unanticipated exceptions (database faults, parse errors,
|
||
/// null-reference, etc.) must NOT be this type, so that <c>MapFault</c> can return
|
||
/// a generic message for them and avoid leaking internal detail.
|
||
/// </summary>
|
||
public class ManagementCommandException : Exception
|
||
{
|
||
/// <summary>Initializes a new instance with the given caller-safe error message.</summary>
|
||
/// <param name="message">The error message to surface verbatim to the HTTP/CLI caller.</param>
|
||
public ManagementCommandException(string message) : base(message) { }
|
||
|
||
/// <summary>
|
||
/// Initializes a new instance with a caller-safe message AND a machine-readable error code
|
||
/// that replaces the generic <c>COMMAND_FAILED</c> on the <c>ManagementError</c> reply
|
||
/// (Flow Debugger design 2026-09-05 §7.1: <c>STANDBY_NODE</c>, <c>NO_TRIGGER_WITHIN_WINDOW</c>).
|
||
/// </summary>
|
||
/// <param name="message">The error message to surface verbatim to the HTTP/CLI caller.</param>
|
||
/// <param name="code">The error code to surface as <c>ManagementError.ErrorCode</c>.</param>
|
||
public ManagementCommandException(string message, string code) : base(message) { Code = code; }
|
||
|
||
/// <summary>Optional machine-readable error code; null = <c>COMMAND_FAILED</c>.</summary>
|
||
public string? Code { get; }
|
||
}
|