fix(management-service): resolve ManagementService-014..017 — site-scope enforcement on QueryDeployments, atomic override validation, curated fault messages, test coverage

This commit is contained in:
Joseph Doherty
2026-05-17 03:18:33 -04:00
parent 73a393076a
commit bf6bd8de5a
3 changed files with 447 additions and 64 deletions

View File

@@ -4,6 +4,7 @@ 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;
@@ -128,7 +129,17 @@ public class ManagementActor : ReceiveActor
_logger.LogError(cause, "Management command {Command} failed (CorrelationId={CorrelationId})",
command.GetType().Name, correlationId);
return new ManagementError(correlationId, cause.Message, "COMMAND_FAILED");
// 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
@@ -173,6 +184,7 @@ public class ManagementActor : ReceiveActor
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
or GetDeploymentDiffCommand
or MgmtDeployArtifactsCommand
or QueryDeploymentsCommand
or RetryParkedMessageCommand or DiscardParkedMessageCommand
or DebugSnapshotCommand => "Deployment",
@@ -303,7 +315,7 @@ public class ManagementActor : ReceiveActor
// Deployments
MgmtDeployArtifactsCommand cmd => await HandleDeployArtifacts(sp, cmd, user.Username),
QueryDeploymentsCommand cmd => await HandleQueryDeployments(sp, cmd),
QueryDeploymentsCommand cmd => await HandleQueryDeployments(sp, cmd, user),
GetDeploymentDiffCommand cmd => await HandleGetDeploymentDiff(sp, cmd, user),
// Audit Log
@@ -428,7 +440,7 @@ public class ManagementActor : ReceiveActor
var result = await svc.CreateTemplateAsync(cmd.Name, cmd.Description, cmd.ParentTemplateId, user);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleUpdateTemplate(IServiceProvider sp, UpdateTemplateCommand cmd, string user)
@@ -437,7 +449,7 @@ public class ManagementActor : ReceiveActor
var result = await svc.UpdateTemplateAsync(cmd.TemplateId, cmd.Name, cmd.Description, cmd.ParentTemplateId, user);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleDeleteTemplate(IServiceProvider sp, DeleteTemplateCommand cmd, string user)
@@ -446,7 +458,7 @@ public class ManagementActor : ReceiveActor
var result = await svc.DeleteTemplateAsync(cmd.TemplateId, user);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleValidateTemplate(IServiceProvider sp, ValidateTemplateCommand cmd)
@@ -455,7 +467,7 @@ public class ManagementActor : ReceiveActor
// Load the template with all members
var template = await repo.GetTemplateWithChildrenAsync(cmd.TemplateId)
?? throw new InvalidOperationException($"Template with ID {cmd.TemplateId} not found.");
?? throw new ManagementCommandException($"Template with ID {cmd.TemplateId} not found.");
var attributes = await repo.GetAttributesByTemplateIdAsync(cmd.TemplateId);
var alarms = await repo.GetAlarmsByTemplateIdAsync(cmd.TemplateId);
@@ -527,35 +539,35 @@ public class ManagementActor : ReceiveActor
{
var svc = sp.GetRequiredService<TemplateFolderService>();
var result = await svc.CreateFolderAsync(cmd.Name, cmd.ParentFolderId, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
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 InvalidOperationException(result.Error);
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 InvalidOperationException(result.Error);
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 InvalidOperationException(result.Error);
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 InvalidOperationException(result.Error);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
// ========================================================================
@@ -589,7 +601,7 @@ public class ManagementActor : ReceiveActor
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 InvalidOperationException(result.Error);
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;
}
@@ -601,7 +613,7 @@ public class ManagementActor : ReceiveActor
var result = await svc.DeployInstanceAsync(cmd.InstanceId, user.Username);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleEnableInstance(IServiceProvider sp, MgmtEnableInstanceCommand cmd, AuthenticatedUser user)
@@ -611,7 +623,7 @@ public class ManagementActor : ReceiveActor
var result = await svc.EnableInstanceAsync(cmd.InstanceId, user.Username);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleDisableInstance(IServiceProvider sp, MgmtDisableInstanceCommand cmd, AuthenticatedUser user)
@@ -621,7 +633,7 @@ public class ManagementActor : ReceiveActor
var result = await svc.DisableInstanceAsync(cmd.InstanceId, user.Username);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleDeleteInstance(IServiceProvider sp, MgmtDeleteInstanceCommand cmd, AuthenticatedUser user)
@@ -631,7 +643,7 @@ public class ManagementActor : ReceiveActor
var result = await svc.DeleteInstanceAsync(cmd.InstanceId, user.Username);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleSetConnectionBindings(IServiceProvider sp, SetConnectionBindingsCommand cmd, AuthenticatedUser user)
@@ -641,18 +653,45 @@ public class ManagementActor : ReceiveActor
var result = await svc.SetConnectionBindingsAsync(cmd.InstanceId, cmd.Bindings, user.Username);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);
: 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 InvalidOperationException(result.Error);
if (!result.IsSuccess) throw new ManagementCommandException(result.Error);
results.Add(result.Value);
}
return results;
@@ -665,7 +704,7 @@ public class ManagementActor : ReceiveActor
var result = await svc.AssignToAreaAsync(cmd.InstanceId, cmd.AreaId, user.Username);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleSetInstanceAlarmOverride(IServiceProvider sp, SetInstanceAlarmOverrideCommand cmd, AuthenticatedUser user)
@@ -678,7 +717,7 @@ public class ManagementActor : ReceiveActor
user.Username);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleDeleteInstanceAlarmOverride(IServiceProvider sp, DeleteInstanceAlarmOverrideCommand cmd, AuthenticatedUser user)
@@ -689,7 +728,7 @@ public class ManagementActor : ReceiveActor
cmd.InstanceId, cmd.AlarmCanonicalName, user.Username);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleListInstanceAlarmOverrides(IServiceProvider sp, ListInstanceAlarmOverridesCommand cmd, AuthenticatedUser user)
@@ -706,7 +745,7 @@ public class ManagementActor : ReceiveActor
var result = await svc.GetDeploymentComparisonAsync(cmd.InstanceId);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleRetryParkedMessage(IServiceProvider sp, RetryParkedMessageCommand cmd, AuthenticatedUser user)
@@ -775,7 +814,7 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<ISiteRepository>();
var site = await repo.GetSiteByIdAsync(cmd.SiteId)
?? throw new InvalidOperationException($"Site with ID {cmd.SiteId} not found.");
?? throw new ManagementCommandException($"Site with ID {cmd.SiteId} not found.");
site.Name = cmd.Name;
site.Description = cmd.Description;
site.NodeAAddress = cmd.NodeAAddress;
@@ -796,7 +835,7 @@ public class ManagementActor : ReceiveActor
var site = await repo.GetSiteByIdAsync(cmd.SiteId);
var instances = await repo.GetInstancesBySiteIdAsync(cmd.SiteId);
if (instances.Count > 0)
throw new InvalidOperationException(
throw new ManagementCommandException(
$"Cannot delete site {cmd.SiteId}: it has {instances.Count} instance(s).");
await repo.DeleteSiteAsync(cmd.SiteId);
await repo.SaveChangesAsync();
@@ -876,7 +915,7 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<ISiteRepository>();
var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId)
?? throw new InvalidOperationException($"DataConnection with ID {cmd.DataConnectionId} not found.");
?? throw new ManagementCommandException($"DataConnection with ID {cmd.DataConnectionId} not found.");
conn.Name = cmd.Name;
conn.Protocol = cmd.Protocol;
conn.PrimaryConfiguration = cmd.PrimaryConfiguration;
@@ -931,7 +970,7 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
var def = await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId)
?? throw new InvalidOperationException($"ExternalSystem with ID {cmd.ExternalSystemId} not found.");
?? throw new ManagementCommandException($"ExternalSystem with ID {cmd.ExternalSystemId} not found.");
def.Name = cmd.Name;
def.EndpointUrl = cmd.EndpointUrl;
def.AuthType = cmd.AuthType;
@@ -982,7 +1021,7 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
var method = await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId)
?? throw new InvalidOperationException($"ExternalSystemMethod with ID {cmd.MethodId} not found.");
?? 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;
@@ -1037,7 +1076,7 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<INotificationRepository>();
var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId)
?? throw new InvalidOperationException($"NotificationList with ID {cmd.NotificationListId} not found.");
?? throw new ManagementCommandException($"NotificationList with ID {cmd.NotificationListId} not found.");
list.Name = cmd.Name;
var existingRecipients = await repo.GetRecipientsByListIdAsync(cmd.NotificationListId);
@@ -1079,7 +1118,7 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<INotificationRepository>();
var config = await repo.GetSmtpConfigurationByIdAsync(cmd.SmtpConfigId)
?? throw new InvalidOperationException($"SmtpConfiguration with ID {cmd.SmtpConfigId} not found.");
?? throw new ManagementCommandException($"SmtpConfiguration with ID {cmd.SmtpConfigId} not found.");
config.Host = cmd.Server;
config.Port = cmd.Port;
config.AuthType = cmd.AuthMode;
@@ -1114,7 +1153,7 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<ISecurityRepository>();
var mapping = await repo.GetMappingByIdAsync(cmd.MappingId)
?? throw new InvalidOperationException($"RoleMapping with ID {cmd.MappingId} not found.");
?? throw new ManagementCommandException($"RoleMapping with ID {cmd.MappingId} not found.");
mapping.LdapGroupName = cmd.LdapGroupName;
mapping.Role = cmd.Role;
await repo.UpdateMappingAsync(mapping);
@@ -1168,15 +1207,45 @@ public class ManagementActor : ReceiveActor
var result = await svc.DeployToAllSitesAsync(user);
return result.IsSuccess
? result.Value
: throw new InvalidOperationException(result.Error);
: throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleQueryDeployments(IServiceProvider sp, QueryDeploymentsCommand cmd)
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);
return await repo.GetAllDeploymentRecordsAsync();
}
// 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;
}
// ========================================================================
@@ -1223,7 +1292,7 @@ public class ManagementActor : ReceiveActor
IsLocked = cmd.IsLocked
};
var result = await svc.AddAttributeAsync(cmd.TemplateId, attr, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleUpdateAttribute(IServiceProvider sp, UpdateTemplateAttributeCommand cmd, string user)
@@ -1238,14 +1307,14 @@ public class ManagementActor : ReceiveActor
IsLocked = cmd.IsLocked
};
var result = await svc.UpdateAttributeAsync(cmd.AttributeId, attr, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
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 InvalidOperationException(result.Error);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleAddAlarm(IServiceProvider sp, AddTemplateAlarmCommand cmd, string user)
@@ -1260,7 +1329,7 @@ public class ManagementActor : ReceiveActor
IsLocked = cmd.IsLocked
};
var result = await svc.AddAlarmAsync(cmd.TemplateId, alarm, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleUpdateAlarm(IServiceProvider sp, UpdateTemplateAlarmCommand cmd, string user)
@@ -1275,14 +1344,14 @@ public class ManagementActor : ReceiveActor
IsLocked = cmd.IsLocked
};
var result = await svc.UpdateAlarmAsync(cmd.AlarmId, alarm, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
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 InvalidOperationException(result.Error);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleAddScript(IServiceProvider sp, AddTemplateScriptCommand cmd, string user)
@@ -1297,7 +1366,7 @@ public class ManagementActor : ReceiveActor
ReturnDefinition = cmd.ReturnDefinition
};
var result = await svc.AddScriptAsync(cmd.TemplateId, script, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleUpdateScript(IServiceProvider sp, UpdateTemplateScriptCommand cmd, string user)
@@ -1312,28 +1381,28 @@ public class ManagementActor : ReceiveActor
ReturnDefinition = cmd.ReturnDefinition
};
var result = await svc.UpdateScriptAsync(cmd.ScriptId, script, user);
return result.IsSuccess ? result.Value : throw new InvalidOperationException(result.Error);
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 InvalidOperationException(result.Error);
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 InvalidOperationException(result.Error);
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 InvalidOperationException(result.Error);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
// ========================================================================
@@ -1356,21 +1425,21 @@ public class ManagementActor : ReceiveActor
{
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 InvalidOperationException(result.Error);
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 InvalidOperationException(result.Error);
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 InvalidOperationException(result.Error);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
// ========================================================================
@@ -1403,7 +1472,7 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
var def = await repo.GetDatabaseConnectionByIdAsync(cmd.DatabaseConnectionId)
?? throw new InvalidOperationException($"DatabaseConnection with ID {cmd.DatabaseConnectionId} not found.");
?? throw new ManagementCommandException($"DatabaseConnection with ID {cmd.DatabaseConnectionId} not found.");
def.Name = cmd.Name;
def.ConnectionString = cmd.ConnectionString;
await repo.UpdateDatabaseConnectionAsync(def);
@@ -1457,7 +1526,7 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
var method = await repo.GetApiMethodByIdAsync(cmd.ApiMethodId)
?? throw new InvalidOperationException($"ApiMethod with ID {cmd.ApiMethodId} not found.");
?? throw new ManagementCommandException($"ApiMethod with ID {cmd.ApiMethodId} not found.");
method.Script = cmd.Script;
method.TimeoutSeconds = cmd.TimeoutSeconds;
method.ParameterDefinitions = cmd.ParameterDefinitions;
@@ -1489,7 +1558,7 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
var key = await repo.GetApiKeyByIdAsync(cmd.ApiKeyId)
?? throw new InvalidOperationException($"ApiKey with ID {cmd.ApiKeyId} not found.");
?? throw new ManagementCommandException($"ApiKey with ID {cmd.ApiKeyId} not found.");
key.IsEnabled = cmd.IsEnabled;
await repo.UpdateApiKeyAsync(key);
await repo.SaveChangesAsync();
@@ -1530,7 +1599,7 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
var area = await repo.GetAreaByIdAsync(cmd.AreaId)
?? throw new InvalidOperationException($"Area with ID {cmd.AreaId} not found.");
?? throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found.");
area.Name = cmd.Name;
await repo.UpdateAreaAsync(area);
await repo.SaveChangesAsync();
@@ -1576,13 +1645,13 @@ public class ManagementActor : ReceiveActor
{
var instanceRepo = sp.GetRequiredService<ITemplateEngineRepository>();
var instance = await instanceRepo.GetInstanceByIdAsync(cmd.InstanceId)
?? throw new InvalidOperationException($"Instance {cmd.InstanceId} not found.");
?? 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 InvalidOperationException($"Site {instance.SiteId} not found.");
?? throw new ManagementCommandException($"Site {instance.SiteId} not found.");
var commService = sp.GetRequiredService<CommunicationService>();
var request = new DebugSnapshotRequest(instance.UniqueName, Guid.NewGuid().ToString("N"));
@@ -1597,3 +1666,16 @@ 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) { }
}