PreviewAsync can emit multiple ImportPreviewItem rows for the same (EntityType, Name) -- one per modified member of a template, for example. ApplyAsync internally calls .ToDictionary() on the resolutions list and throws ArgumentException on duplicate keys. The Central UI's BuildDefaultResolutions already dedupes via a dictionary assignment (last-write-wins). Mirror that in the CLI handler so 'bundle import' tolerates the duplicate-rows shape the preview returns.
1915 lines
95 KiB
C#
1915 lines
95 KiB
C#
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 ScadaLink.Commons.Entities.Deployment;
|
|
using ScadaLink.Commons.Entities.ExternalSystems;
|
|
using ScadaLink.Commons.Entities.InboundApi;
|
|
using ScadaLink.Commons.Entities.Instances;
|
|
using ScadaLink.Commons.Entities.Scripts;
|
|
using ScadaLink.Commons.Entities.Templates;
|
|
using ScadaLink.Commons.Entities.Notifications;
|
|
using ScadaLink.Commons.Entities.Security;
|
|
using ScadaLink.Commons.Entities.Sites;
|
|
using ScadaLink.Commons.Interfaces.Repositories;
|
|
using ScadaLink.Commons.Interfaces.Services;
|
|
using ScadaLink.Commons.Messages.DebugView;
|
|
using ScadaLink.Commons.Interfaces.Transport;
|
|
using ScadaLink.Commons.Messages.Management;
|
|
using ScadaLink.Commons.Messages.RemoteQuery;
|
|
using ScadaLink.Commons.Types.InboundApi;
|
|
using ScadaLink.Commons.Types.Transport;
|
|
using ScadaLink.DeploymentManager;
|
|
using ScadaLink.HealthMonitoring;
|
|
using ScadaLink.Communication;
|
|
using ScadaLink.Security;
|
|
using ScadaLink.TemplateEngine;
|
|
using ScadaLink.TemplateEngine.Services;
|
|
|
|
namespace ScadaLink.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;
|
|
|
|
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>
|
|
public static SupervisorStrategy CreateSupervisorStrategy() =>
|
|
new OneForOneStrategy(
|
|
maxNrOfRetries: -1,
|
|
withinTimeRange: System.Threading.Timeout.InfiniteTimeSpan,
|
|
decider: Decider.From(_ => Directive.Resume));
|
|
|
|
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>
|
|
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
|
|
{
|
|
// Admin operations
|
|
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
|
|
or ListRoleMappingsCommand or CreateRoleMappingCommand
|
|
or UpdateRoleMappingCommand or DeleteRoleMappingCommand
|
|
or ListApiKeysCommand or CreateApiKeyCommand or DeleteApiKeyCommand
|
|
or UpdateApiKeyCommand
|
|
or ListScopeRulesCommand or AddScopeRuleCommand or DeleteScopeRuleCommand => "Admin",
|
|
|
|
// Design 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 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 => "Design",
|
|
|
|
// Transport import operations (mirror the Central UI gating: Admin
|
|
// for inbound bundle handling because they mutate cross-cutting
|
|
// configuration; Export stays Design because it only reads).
|
|
PreviewBundleCommand or ImportBundleCommand => "Admin",
|
|
|
|
// Deployment operations
|
|
CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtEnableInstanceCommand
|
|
or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
|
|
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
|
|
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
|
|
or GetDeploymentDiffCommand
|
|
or MgmtDeployArtifactsCommand
|
|
or QueryDeploymentsCommand
|
|
or RetryParkedMessageCommand or DiscardParkedMessageCommand
|
|
or DebugSnapshotCommand => "Deployment",
|
|
|
|
// 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),
|
|
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),
|
|
|
|
// 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),
|
|
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),
|
|
|
|
// 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 Deployment
|
|
/// and the target site is not in their permitted sites.
|
|
/// Users with Admin or Design roles, or system-wide Deployment, 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("Admin", 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("Admin", 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("Admin", 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(),
|
|
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("Admin", 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?> 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);
|
|
}
|
|
|
|
// ========================================================================
|
|
// 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("Admin", 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;
|
|
}
|
|
|
|
private static async Task<object?> HandleListSmtpConfigs(IServiceProvider sp)
|
|
{
|
|
var repo = sp.GetRequiredService<INotificationRepository>();
|
|
return await repo.GetAllSmtpConfigurationsAsync();
|
|
}
|
|
|
|
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();
|
|
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, config);
|
|
return config;
|
|
}
|
|
|
|
// ========================================================================
|
|
// 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)
|
|
{
|
|
var repo = sp.GetRequiredService<IInboundApiRepository>();
|
|
var keys = await repo.GetAllApiKeysAsync();
|
|
|
|
// ConfigurationDatabase-012: list/read paths must not expose the stored key
|
|
// hash — it is a credential artifact. Only identity and status are returned;
|
|
// the plaintext key is shown once at creation and is never retrievable.
|
|
return keys
|
|
.Select(k => new { k.Id, k.Name, k.IsEnabled })
|
|
.ToList();
|
|
}
|
|
|
|
private static async Task<object?> HandleCreateApiKey(IServiceProvider sp, CreateApiKeyCommand cmd, string user)
|
|
{
|
|
var repo = sp.GetRequiredService<IInboundApiRepository>();
|
|
|
|
// ConfigurationDatabase-012: generate a high-entropy random key, persist only
|
|
// its peppered hash, and return the plaintext to the caller exactly once. The
|
|
// plaintext is never stored — the ApiKey entity carries only KeyHash.
|
|
var hasher = sp.GetService<IApiKeyHasher>() ?? ApiKeyHasher.Default;
|
|
var plaintextKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
|
var apiKey = ApiKey.FromHash(cmd.Name, hasher.Hash(plaintextKey));
|
|
apiKey.IsEnabled = true;
|
|
|
|
await repo.AddApiKeyAsync(apiKey);
|
|
await repo.SaveChangesAsync();
|
|
await AuditAsync(sp, user, "Create", "ApiKey", apiKey.Id.ToString(), apiKey.Name,
|
|
new { apiKey.Id, apiKey.Name, apiKey.IsEnabled });
|
|
|
|
// The plaintext key is shown to the operator only here, in the create response;
|
|
// it cannot be retrieved later. The stored hash is deliberately not returned.
|
|
return new
|
|
{
|
|
apiKey.Id,
|
|
apiKey.Name,
|
|
apiKey.IsEnabled,
|
|
PlaintextKey = plaintextKey,
|
|
};
|
|
}
|
|
|
|
private static async Task<object?> HandleDeleteApiKey(IServiceProvider sp, DeleteApiKeyCommand cmd, string user)
|
|
{
|
|
var repo = sp.GetRequiredService<IInboundApiRepository>();
|
|
await repo.DeleteApiKeyAsync(cmd.ApiKeyId);
|
|
await repo.SaveChangesAsync();
|
|
await AuditAsync(sp, user, "Delete", "ApiKey", cmd.ApiKeyId.ToString(), cmd.ApiKeyId.ToString(), null);
|
|
return true;
|
|
}
|
|
|
|
// ========================================================================
|
|
// 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("Admin", StringComparer.OrdinalIgnoreCase))
|
|
return records;
|
|
|
|
var permittedIds = new HashSet<string>(user.PermittedSiteIds);
|
|
var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
|
|
var instanceSiteCache = new Dictionary<int, int?>();
|
|
var scoped = new List<DeploymentRecord>();
|
|
foreach (var record in records)
|
|
{
|
|
if (!instanceSiteCache.TryGetValue(record.InstanceId, out var siteId))
|
|
{
|
|
var instance = await templateRepo.GetInstanceByIdAsync(record.InstanceId);
|
|
siteId = instance?.SiteId;
|
|
instanceSiteCache[record.InstanceId] = siteId;
|
|
}
|
|
if (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 attr = new TemplateAttribute(cmd.Name)
|
|
{
|
|
DataType = Enum.Parse<ScadaLink.Commons.Types.Enums.DataType>(cmd.DataType, ignoreCase: true),
|
|
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 attr = new TemplateAttribute(cmd.Name)
|
|
{
|
|
DataType = Enum.Parse<ScadaLink.Commons.Types.Enums.DataType>(cmd.DataType, ignoreCase: true),
|
|
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);
|
|
}
|
|
|
|
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<ScadaLink.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<ScadaLink.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);
|
|
}
|
|
|
|
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)
|
|
{
|
|
var repo = sp.GetRequiredService<IInboundApiRepository>();
|
|
var key = await repo.GetApiKeyByIdAsync(cmd.ApiKeyId)
|
|
?? throw new ManagementCommandException($"ApiKey with ID {cmd.ApiKeyId} not found.");
|
|
key.IsEnabled = cmd.IsEnabled;
|
|
await repo.UpdateApiKeyAsync(key);
|
|
await repo.SaveChangesAsync();
|
|
await AuditAsync(sp, user, "Update", "ApiKey", key.Id.ToString(), key.Name, new { key.Id, key.Name, key.IsEnabled });
|
|
return key;
|
|
}
|
|
|
|
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();
|
|
var apiKeys = await inboundRepo.GetAllApiKeysAsync();
|
|
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"),
|
|
ApiKeyIds: ResolveIds(apiKeys, cmd.ApiKeyNames, k => k.Name, k => k.Id, "API key"),
|
|
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
|
|
{
|
|
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
|
|
{
|
|
public ManagementCommandException(string message) : base(message) { }
|
|
}
|