Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs
T

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) { }
}