2478 lines
124 KiB
C#
2478 lines
124 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 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.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.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 ClusterClient).
|
|
/// 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 (finding
|
|
/// ManagementService-005).
|
|
/// </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 (finding ManagementService-007).
|
|
/// </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>
|
|
/// <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>
|
|
public static string SerializeResult(object? result) =>
|
|
JsonSerializer.Serialize(result, ResultSerializerOptions);
|
|
|
|
private void HandleEnvelope(ManagementEnvelope envelope)
|
|
{
|
|
var sender = Sender;
|
|
var correlationId = envelope.CorrelationId;
|
|
var user = envelope.User;
|
|
|
|
// Check authorization
|
|
var requiredRole = GetRequiredRole(envelope.Command);
|
|
if (requiredRole != null && !user.Roles.Contains(requiredRole, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
sender.Tell(new ManagementUnauthorized(correlationId,
|
|
$"Role '{requiredRole}' 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);
|
|
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 (finding ManagementService-016).
|
|
var clientMessage = cause is ManagementCommandException
|
|
? cause.Message
|
|
: $"An internal error occurred (CorrelationId={correlationId}).";
|
|
return new ManagementError(correlationId, clientMessage, "COMMAND_FAILED");
|
|
}
|
|
|
|
private static string? GetRequiredRole(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 (Management-018).
|
|
or QueryAuditLogCommand => Roles.Administrator,
|
|
|
|
// Designer operations
|
|
CreateAreaCommand or DeleteAreaCommand
|
|
or 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 UpdateSmtpConfigCommand
|
|
or CreateDataConnectionCommand or UpdateDataConnectionCommand
|
|
or DeleteDataConnectionCommand
|
|
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 CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand
|
|
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
|
|
or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand
|
|
or UpdateAreaCommand
|
|
or CreateTemplateFolderCommand or RenameTemplateFolderCommand
|
|
or MoveTemplateFolderCommand or DeleteTemplateFolderCommand
|
|
or MoveTemplateToFolderCommand
|
|
or ExportBundleCommand => Roles.Designer,
|
|
|
|
// 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 => Roles.Administrator,
|
|
|
|
// Deployer operations
|
|
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
|
|
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
|
|
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
|
|
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
|
|
or SetInstanceNativeAlarmSourceOverrideCommand or DeleteInstanceNativeAlarmSourceOverrideCommand
|
|
or GetDeploymentDiffCommand
|
|
or MgmtDeployArtifactsCommand
|
|
or QueryDeploymentsCommand
|
|
or RetryParkedMessageCommand or DiscardParkedMessageCommand
|
|
or DebugSnapshotCommand => Roles.Deployer,
|
|
|
|
// Two-person secured write (M7 / T14b). 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 => Roles.Operator,
|
|
RejectSecuredWriteCommand or ApproveSecuredWriteCommand => Roles.Verifier,
|
|
// ListSecuredWritesCommand is read-only -> falls through to "any
|
|
// authenticated user" below, like the other list/query commands.
|
|
|
|
// Read-only queries -- any authenticated user
|
|
_ => null
|
|
};
|
|
|
|
private async Task<object?> DispatchCommand(IServiceProvider sp, object command, AuthenticatedUser user)
|
|
{
|
|
return command switch
|
|
{
|
|
// Templates
|
|
ListTemplatesCommand => await HandleListTemplates(sp),
|
|
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),
|
|
|
|
// 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),
|
|
UpdateTemplateNativeAlarmSourceCommand cmd => await HandleUpdateNativeAlarmSource(sp, cmd),
|
|
DeleteTemplateNativeAlarmSourceCommand cmd => await HandleDeleteNativeAlarmSource(sp, cmd),
|
|
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),
|
|
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),
|
|
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),
|
|
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),
|
|
|
|
// Data Connections
|
|
ListDataConnectionsCommand cmd => await HandleListDataConnections(sp, cmd),
|
|
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),
|
|
|
|
// 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),
|
|
|
|
// 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),
|
|
|
|
// 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.Username),
|
|
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),
|
|
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 (M7 / T14b). Approve executes the write — once a
|
|
// Verifier wins the compare-and-swap the value is relayed to the site
|
|
// MxGateway connection (Task C3).
|
|
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),
|
|
|
|
// Transport (#24) bundle operations
|
|
ExportBundleCommand cmd => await HandleExportBundle(sp, cmd, user.Username),
|
|
PreviewBundleCommand cmd => await HandlePreviewBundle(sp, cmd),
|
|
ImportBundleCommand cmd => await HandleImportBundle(sp, cmd, user.Username),
|
|
|
|
// 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 ClusterClient sender with
|
|
// no role requirement; the command now falls through to the default below
|
|
// (finding ManagementService-011).
|
|
_ => 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>
|
|
/// Resolves the site ID for an instance and enforces site-scope.
|
|
/// </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 != null)
|
|
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.
|
|
/// </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 != null)
|
|
EnforceSiteScope(user, site.Id);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logs an audit entry after a successful mutation.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Audit-logging contract (finding ManagementService-009). 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)
|
|
{
|
|
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
|
return await repo.GetAllTemplatesAsync();
|
|
}
|
|
|
|
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()
|
|
};
|
|
|
|
// Run full validation pipeline (collisions, script compilation, trigger refs, bindings)
|
|
var validationService = new TemplateEngine.Validation.ValidationService();
|
|
var validationResult = validationService.Validate(flatConfig);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// ========================================================================
|
|
// 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?> 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();
|
|
}
|
|
return instances;
|
|
}
|
|
|
|
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 != 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);
|
|
}
|
|
|
|
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 (finding ManagementService-015).
|
|
// 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.");
|
|
}
|
|
|
|
var svc = sp.GetRequiredService<InstanceService>();
|
|
var results = new List<InstanceAttributeOverride>();
|
|
foreach (var (attrName, overrideValue) in cmd.Overrides)
|
|
{
|
|
var result = await svc.SetAttributeOverrideAsync(cmd.InstanceId, attrName, overrideValue, user.Username);
|
|
if (!result.IsSuccess) throw new ManagementCommandException(result.Error);
|
|
results.Add(result.Value);
|
|
}
|
|
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>();
|
|
|
|
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?> 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 (M7 / T14b)
|
|
//
|
|
// Two-person workflow: an Operator submits a pending write against an
|
|
// MxGateway data connection; a distinct Verifier approves (Task C3) 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="IAuditLogRepository.InsertIfNotExistsAsync"/>) — mirrors the
|
|
/// Notification Outbox / 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>
|
|
/// Standing audit invariant: an audit-write failure NEVER aborts the secured-write
|
|
/// action. Every exception (repository resolution OR the insert) is caught, logged at
|
|
/// warning, and swallowed — the caller's own success/failure path is authoritative.
|
|
/// </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
|
|
}));
|
|
|
|
using var scope = sp.CreateScope();
|
|
var auditRepo = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
|
|
await auditRepo.InsertIfNotExistsAsync(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);
|
|
}
|
|
}
|
|
|
|
private static async Task<object?> HandleSubmitSecuredWrite(
|
|
IServiceProvider sp, SubmitSecuredWriteCommand cmd, AuthenticatedUser user)
|
|
{
|
|
var siteRepo = sp.GetRequiredService<ISiteRepository>();
|
|
var site = await siteRepo.GetSiteByIdentifierAsync(cmd.SiteId)
|
|
?? throw new ManagementCommandException($"Site '{cmd.SiteId}' not found.");
|
|
|
|
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);
|
|
|
|
// T14b — 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)
|
|
?? throw new ManagementCommandException($"Secured write {cmd.Id} not found.");
|
|
|
|
if (!string.Equals(row.Status, "Pending", StringComparison.Ordinal))
|
|
throw new ManagementCommandException(
|
|
$"Secured write {cmd.Id} is '{row.Status}', not Pending; it cannot be approved.");
|
|
|
|
// 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;
|
|
|
|
// T14b — 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);
|
|
|
|
// 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 C2 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);
|
|
}
|
|
|
|
// C3 robustness fix: Decode is UNGUARDED in the pre-T14b code — 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.
|
|
await repo.UpdateAsync(row);
|
|
|
|
// T14b — 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)
|
|
?? throw new ManagementCommandException($"Secured write {cmd.Id} not found.");
|
|
|
|
if (!string.Equals(entity.Status, "Pending", StringComparison.Ordinal))
|
|
throw new ManagementCommandException(
|
|
$"Secured write {cmd.Id} is '{entity.Status}', not Pending; it cannot be 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.");
|
|
|
|
entity.Status = "Rejected";
|
|
entity.VerifierUser = user.Username;
|
|
entity.VerifierComment = cmd.Comment;
|
|
entity.DecidedAtUtc = DateTime.UtcNow;
|
|
|
|
// UpdateAsync overwrites all columns -> pass the fully-populated entity.
|
|
await repo.UpdateAsync(entity);
|
|
|
|
// T14b — reject is a terminal lifecycle event (canonical Discarded outcome).
|
|
// 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)
|
|
{
|
|
var repo = sp.GetRequiredService<ISecuredWriteRepository>();
|
|
var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip: 0, take: 200);
|
|
return new SecuredWriteListResult(rows.Select(ToSecuredWriteDto).ToList());
|
|
}
|
|
|
|
// ========================================================================
|
|
// 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;
|
|
}
|
|
|
|
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 != 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();
|
|
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
|
|
// ========================================================================
|
|
|
|
private static async Task<object?> HandleListDataConnections(IServiceProvider sp, ListDataConnectionsCommand cmd)
|
|
{
|
|
var repo = sp.GetRequiredService<ISiteRepository>();
|
|
if (cmd.SiteId.HasValue)
|
|
return await repo.GetDataConnectionsBySiteIdAsync(cmd.SiteId.Value);
|
|
return await repo.GetAllDataConnectionsAsync();
|
|
}
|
|
|
|
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 != null)
|
|
EnforceSiteScope(user, conn.SiteId);
|
|
return 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();
|
|
await AuditAsync(sp, user, "Create", "DataConnection", conn.Id.ToString(), conn.Name, conn);
|
|
return conn;
|
|
}
|
|
|
|
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;
|
|
conn.PrimaryConfiguration = cmd.PrimaryConfiguration;
|
|
conn.BackupConfiguration = cmd.BackupConfiguration;
|
|
conn.FailoverRetryCount = cmd.FailoverRetryCount;
|
|
await repo.UpdateDataConnectionAsync(conn);
|
|
await repo.SaveChangesAsync();
|
|
await AuditAsync(sp, user, "Update", "DataConnection", conn.Id.ToString(), conn.Name, conn);
|
|
return conn;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
|
|
// ========================================================================
|
|
// External System handlers
|
|
// ========================================================================
|
|
|
|
private static async Task<object?> HandleListExternalSystems(IServiceProvider sp)
|
|
{
|
|
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
|
return await repo.GetAllExternalSystemsAsync();
|
|
}
|
|
|
|
private static async Task<object?> HandleGetExternalSystem(IServiceProvider sp, GetExternalSystemCommand cmd)
|
|
{
|
|
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
|
return await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId);
|
|
}
|
|
|
|
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
|
|
};
|
|
await repo.AddExternalSystemAsync(def);
|
|
await repo.SaveChangesAsync();
|
|
await AuditAsync(sp, user, "Create", "ExternalSystem", def.Id.ToString(), def.Name, def);
|
|
return def;
|
|
}
|
|
|
|
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;
|
|
def.AuthConfiguration = cmd.AuthConfiguration;
|
|
await repo.UpdateExternalSystemAsync(def);
|
|
await repo.SaveChangesAsync();
|
|
await AuditAsync(sp, user, "Update", "ExternalSystem", def.Id.ToString(), def.Name, def);
|
|
return def;
|
|
}
|
|
|
|
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);
|
|
foreach (var email in cmd.RecipientEmails)
|
|
{
|
|
list.Recipients.Add(new NotificationRecipient(email, email));
|
|
}
|
|
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.");
|
|
list.Name = cmd.Name;
|
|
|
|
var existingRecipients = await repo.GetRecipientsByListIdAsync(cmd.NotificationListId);
|
|
foreach (var r in existingRecipients)
|
|
{
|
|
await repo.DeleteRecipientAsync(r.Id);
|
|
}
|
|
|
|
foreach (var email in cmd.RecipientEmails)
|
|
{
|
|
await repo.AddRecipientAsync(new NotificationRecipient(email, email)
|
|
{
|
|
NotificationListId = cmd.NotificationListId
|
|
});
|
|
}
|
|
|
|
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>
|
|
/// MgmtSvc-020: 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 / CD-012's fix.
|
|
/// </summary>
|
|
private static object SmtpConfigPublicShape(Commons.Entities.Notifications.SmtpConfiguration c) =>
|
|
new
|
|
{
|
|
c.Id,
|
|
c.Host,
|
|
c.Port,
|
|
c.AuthType,
|
|
c.FromAddress,
|
|
c.TlsMode,
|
|
c.ConnectionTimeoutSeconds,
|
|
c.MaxConcurrentConnections,
|
|
c.MaxRetries,
|
|
c.RetryDelay,
|
|
HasCredentials = !string.IsNullOrEmpty(c.Credentials),
|
|
};
|
|
|
|
private static async Task<object?> HandleListSmtpConfigs(IServiceProvider sp)
|
|
{
|
|
var repo = sp.GetRequiredService<INotificationRepository>();
|
|
var configs = await repo.GetAllSmtpConfigurationsAsync();
|
|
// MgmtSvc-020: 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;
|
|
await repo.UpdateSmtpConfigurationAsync(config);
|
|
await repo.SaveChangesAsync();
|
|
// MgmtSvc-020: 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;
|
|
}
|
|
|
|
// ========================================================================
|
|
// Security handlers
|
|
// ========================================================================
|
|
|
|
private static async Task<object?> HandleListRoleMappings(IServiceProvider sp)
|
|
{
|
|
var repo = sp.GetRequiredService<ISecurityRepository>();
|
|
return await repo.GetAllMappingsAsync();
|
|
}
|
|
|
|
private static async Task<object?> HandleCreateRoleMapping(IServiceProvider sp, CreateRoleMappingCommand cmd, string user)
|
|
{
|
|
var repo = sp.GetRequiredService<ISecurityRepository>();
|
|
var mapping = new LdapGroupMapping(cmd.LdapGroupName, cmd.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 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 = cmd.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 (C2): 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 (C2): 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, string user)
|
|
{
|
|
var svc = sp.GetRequiredService<ArtifactDeploymentService>();
|
|
var result = await svc.DeployToAllSitesAsync(user);
|
|
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>();
|
|
|
|
// Instance-scoped query: enforce against the target instance's site
|
|
// before reading its deployment history (finding ManagementService-014).
|
|
if (cmd.InstanceId.HasValue)
|
|
{
|
|
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId.Value);
|
|
return await repo.GetDeploymentsByInstanceIdAsync(cmd.InstanceId.Value);
|
|
}
|
|
|
|
// Unfiltered query: a site-scoped Deployment user must only see records
|
|
// for instances at sites within their scope. DeploymentRecord has no
|
|
// SiteId, so resolve each record's instance to its site and filter
|
|
// (mirrors the HandleListInstances / HandleListSites filter pattern).
|
|
var records = await repo.GetAllDeploymentRecordsAsync();
|
|
if (user.PermittedSiteIds.Length == 0 || user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
|
return records;
|
|
|
|
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
|
|
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
|
|
|
// ManagementService-023: pre-load all instances ONCE via the repository's
|
|
// bulk method and build an InstanceId -> SiteId? lookup, instead of issuing
|
|
// GetInstanceByIdAsync per distinct record.InstanceId (textbook N+1). The
|
|
// unfiltered branch now hits the configuration database exactly twice
|
|
// (deployment records + instances) regardless of fleet size.
|
|
var allInstances = await templateRepo.GetAllInstancesAsync();
|
|
var instanceSiteLookup = new Dictionary<int, int?>(allInstances.Count);
|
|
foreach (var instance in allInstances)
|
|
{
|
|
instanceSiteLookup[instance.Id] = instance.SiteId;
|
|
}
|
|
|
|
var scoped = new List<DeploymentRecord>();
|
|
foreach (var record in records)
|
|
{
|
|
if (instanceSiteLookup.TryGetValue(record.InstanceId, out var siteId)
|
|
&& siteId.HasValue
|
|
&& permittedIds.Contains(siteId.Value.ToString()))
|
|
{
|
|
scoped.Add(record);
|
|
}
|
|
}
|
|
return scoped;
|
|
}
|
|
|
|
// ========================================================================
|
|
// Audit Log handler
|
|
// ========================================================================
|
|
|
|
private static async Task<object?> HandleQueryAuditLog(IServiceProvider sp, QueryAuditLogCommand cmd)
|
|
{
|
|
var repo = sp.GetRequiredService<ICentralUiRepository>();
|
|
return await repo.GetAuditLogEntriesAsync(
|
|
cmd.User, cmd.EntityType, cmd.Action, cmd.From, cmd.To,
|
|
page: cmd.Page, pageSize: cmd.PageSize);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 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)
|
|
{
|
|
if (!Enum.TryParse<Commons.Types.Enums.DataType>(dataType, ignoreCase: true, out var parsed))
|
|
throw new ManagementCommandException($"Unrecognised data type '{dataType}'.");
|
|
return parsed;
|
|
}
|
|
|
|
/// <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;
|
|
if (!Enum.TryParse<Commons.Types.Enums.DataType>(elementDataType, ignoreCase: true, out var parsed))
|
|
throw new ManagementCommandException($"Unrecognised element type '{elementDataType}'.");
|
|
return parsed;
|
|
}
|
|
|
|
/// <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 = Enum.Parse<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTriggerType>(cmd.TriggerType, ignoreCase: true),
|
|
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 = Enum.Parse<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTriggerType>(cmd.TriggerType, ignoreCase: true),
|
|
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)
|
|
{
|
|
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();
|
|
return source;
|
|
}
|
|
|
|
private static async Task<object?> HandleUpdateNativeAlarmSource(IServiceProvider sp, UpdateTemplateNativeAlarmSourceCommand cmd)
|
|
{
|
|
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();
|
|
return source;
|
|
}
|
|
|
|
private static async Task<object?> HandleDeleteNativeAlarmSource(IServiceProvider sp, DeleteTemplateNativeAlarmSourceCommand cmd)
|
|
{
|
|
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
|
await repo.DeleteTemplateNativeAlarmSourceAsync(cmd.NativeAlarmSourceId);
|
|
await repo.SaveChangesAsync();
|
|
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
|
|
};
|
|
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
|
|
};
|
|
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);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Database Connection Definition handlers
|
|
// ========================================================================
|
|
|
|
private static async Task<object?> HandleListDatabaseConnections(IServiceProvider sp)
|
|
{
|
|
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
|
return await repo.GetAllDatabaseConnectionsAsync();
|
|
}
|
|
|
|
private static async Task<object?> HandleGetDatabaseConnection(IServiceProvider sp, GetDatabaseConnectionCommand cmd)
|
|
{
|
|
var repo = sp.GetRequiredService<IExternalSystemRepository>();
|
|
return await repo.GetDatabaseConnectionByIdAsync(cmd.DatabaseConnectionId);
|
|
}
|
|
|
|
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 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;
|
|
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 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
|
|
};
|
|
await repo.AddApiMethodAsync(method);
|
|
await repo.SaveChangesAsync();
|
|
sp.GetService<InboundAPI.InboundScriptExecutor>()?.CompileAndRegister(method);
|
|
await AuditAsync(sp, user, "Create", "ApiMethod", method.Id.ToString(), method.Name, method);
|
|
return method;
|
|
}
|
|
|
|
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;
|
|
await repo.UpdateApiMethodAsync(method);
|
|
await repo.SaveChangesAsync();
|
|
sp.GetService<InboundAPI.InboundScriptExecutor>()?.CompileAndRegister(method);
|
|
await AuditAsync(sp, user, "Update", "ApiMethod", method.Id.ToString(), method.Name, method);
|
|
return method;
|
|
}
|
|
|
|
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>()?.RemoveHandler(method.Name);
|
|
await AuditAsync(sp, user, "Delete", "ApiMethod", cmd.ApiMethodId.ToString(), method?.Name ?? cmd.ApiMethodId.ToString(), null);
|
|
return true;
|
|
}
|
|
|
|
// ========================================================================
|
|
// 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 (C2): 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 (C2): 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;
|
|
}
|
|
|
|
// ========================================================================
|
|
// 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,
|
|
cmd.InstanceName, // InstanceId filter
|
|
cmd.Keyword,
|
|
null, // ContinuationToken
|
|
cmd.PageSize,
|
|
DateTimeOffset.UtcNow);
|
|
return await commService.QueryEventLogsAsync(cmd.SiteIdentifier, request);
|
|
}
|
|
|
|
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)
|
|
{
|
|
var instanceRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
|
var instance = await instanceRepo.GetInstanceByIdAsync(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 (#24) bundle operations
|
|
// ========================================================================
|
|
|
|
/// <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)
|
|
{
|
|
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
|
var externalRepo = sp.GetRequiredService<IExternalSystemRepository>();
|
|
var notifRepo = sp.GetRequiredService<INotificationRepository>();
|
|
var inboundRepo = sp.GetRequiredService<IInboundApiRepository>();
|
|
|
|
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();
|
|
// Inbound API keys are not transported between environments (re-arch C4); only methods.
|
|
var apiMethods = await inboundRepo.GetAllApiMethodsAsync();
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
|
|
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);
|
|
var bytes = ms.ToArray();
|
|
return new ExportBundleResult(Convert.ToBase64String(bytes), bytes.Length);
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
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);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
throw new ManagementCommandException(ex.Message);
|
|
}
|
|
catch (System.Security.Cryptography.CryptographicException)
|
|
{
|
|
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
|
|
}
|
|
|
|
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);
|
|
return new PreviewBundleResult(preview.Items, adds, mods, ids, blocks);
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
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);
|
|
}
|
|
catch (ArgumentException ex)
|
|
{
|
|
throw new ManagementCommandException(ex.Message);
|
|
}
|
|
catch (System.Security.Cryptography.CryptographicException)
|
|
{
|
|
throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
|
|
}
|
|
|
|
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}");
|
|
}
|
|
|
|
// 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.
|
|
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);
|
|
}
|
|
|
|
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 (finding
|
|
/// ManagementService-016). 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) { }
|
|
}
|