Resolve all high-severity architecture review findings

This commit is contained in:
Joseph Doherty
2026-09-09 16:13:58 -04:00
parent 8bfe65512e
commit 146a13238f
192 changed files with 3160 additions and 753 deletions
@@ -156,7 +156,9 @@ public class ManagementActor : ReceiveActor
private async Task<object> ProcessCommand(ManagementEnvelope envelope, AuthenticatedUser user)
{
using var scope = _serviceProvider.CreateScope();
var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user, envelope.ClientIp);
var result = IsConfigurationCommand(envelope.Command)
? await ExecuteConfigurationCommandAsync(scope.ServiceProvider, envelope.Command, user, envelope.ClientIp)
: await DispatchCommand(scope.ServiceProvider, envelope.Command, user, envelope.ClientIp);
return new ManagementSuccess(envelope.CorrelationId, SerializeResult(result));
}
@@ -187,6 +189,41 @@ public class ManagementActor : ReceiveActor
return new ManagementError(correlationId, clientMessage, errorCode);
}
/// <summary>Audited database-only application boundary shared by UI and management envelopes.</summary>
public static async Task<object?> ExecuteConfigurationCommandAsync(IServiceProvider services, object command, AuthenticatedUser user, string? clientIp = null)
{
if (!IsConfigurationCommand(command)) throw new ArgumentException("Not a configuration command.", nameof(command));
var required = GetRequiredRoles(command);
if (required is null || !required.Any(r => user.Roles.Contains(r, StringComparer.OrdinalIgnoreCase)))
throw new UnauthorizedAccessException("The current session is not authorized for this configuration change.");
var transaction = services.GetRequiredService<IConfigurationCommandTransaction>();
return await transaction.ExecuteAsync(sp => DispatchCommand(sp, command, user, clientIp));
}
private static bool IsConfigurationCommand(object command) => command is
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
or CreateAreaCommand or UpdateAreaCommand or MoveAreaCommand or DeleteAreaCommand or SetInstanceAreaCommand
or CreateDataConnectionCommand or UpdateDataConnectionCommand or DeleteDataConnectionCommand
or CreateExternalSystemCommand or UpdateExternalSystemCommand or DeleteExternalSystemCommand
or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand
or CreateRoleMappingCommand or UpdateRoleMappingCommand or DeleteRoleMappingCommand or AddScopeRuleCommand or DeleteScopeRuleCommand
or CreateNotificationListCommand or UpdateNotificationListCommand or DeleteNotificationListCommand
or UpdateSmtpConfigCommand or UpdateSmsConfigCommand
or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
or SetInstanceNativeAlarmSourceOverrideCommand or DeleteInstanceNativeAlarmSourceOverrideCommand
or SetInstanceNativeAlarmSourceOverridesCommand or SetInstanceOverridesCommand
or RenameNotificationListCommand or AddNotificationRecipientCommand or DeleteNotificationRecipientCommand
or DeleteInstanceAttributeOverrideCommand;
private static Task AfterConfigurationCommit(IServiceProvider services, Func<Task> action)
{
var pending = services.GetService<IConfigurationCommitActions>();
if (pending is null) return action();
pending.Add(action);
return Task.CompletedTask;
}
// Static readonly role sets for the authorization gate. Single-role arms
// stay one-element arrays so every arm has the uniform any-of shape.
private static readonly string[] AdminOnly = [Roles.Administrator];
@@ -226,6 +263,8 @@ public class ManagementActor : ReceiveActor
/// <returns>The set of roles that satisfy the authorization gate, or <c>null</c> if any authenticated user may issue the command.</returns>
internal static IReadOnlyList<string>? GetRequiredRoles(object command) => command switch
{
RenameNotificationListCommand or AddNotificationRecipientCommand or DeleteNotificationRecipientCommand => DesignerOnly,
DeleteInstanceAttributeOverrideCommand => DeployerOnly,
// Administrator operations
CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
or ListRoleMappingsCommand or CreateRoleMappingCommand
@@ -335,11 +374,15 @@ public class ManagementActor : ReceiveActor
_ => null
};
private async Task<object?> DispatchCommand(
private static async Task<object?> DispatchCommand(
IServiceProvider sp, object command, AuthenticatedUser user, string? clientIp = null)
{
return command switch
{
RenameNotificationListCommand cmd => await HandleRenameNotificationList(sp, cmd, user.Username),
AddNotificationRecipientCommand cmd => await HandleAddNotificationRecipient(sp, cmd, user.Username),
DeleteNotificationRecipientCommand cmd => await HandleDeleteNotificationRecipient(sp, cmd, user.Username),
DeleteInstanceAttributeOverrideCommand cmd => await HandleDeleteAttributeOverride(sp, cmd, user),
// Templates
ListTemplatesCommand cmd => await HandleListTemplates(sp, cmd),
GetTemplateCommand cmd => await HandleGetTemplate(sp, cmd),
@@ -403,10 +446,10 @@ public class ManagementActor : ReceiveActor
UpdateSiteCommand cmd => await HandleUpdateSite(sp, cmd, user.Username),
DeleteSiteCommand cmd => await HandleDeleteSite(sp, cmd, user.Username),
ListAreasCommand cmd => await HandleListAreas(sp, cmd, user),
CreateAreaCommand cmd => await HandleCreateArea(sp, cmd, user.Username),
DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd, user.Username),
UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user.Username),
MoveAreaCommand cmd => await HandleMoveArea(sp, cmd, user.Username),
CreateAreaCommand cmd => await HandleCreateArea(sp, cmd, user),
DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd, user),
UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user),
MoveAreaCommand cmd => await HandleMoveArea(sp, cmd, user),
// Data Connections
ListDataConnectionsCommand cmd => await HandleListDataConnections(sp, cmd, user),
@@ -1845,7 +1888,7 @@ public class ManagementActor : ReceiveActor
await repo.AddSiteAsync(site);
await repo.SaveChangesAsync();
var commService = sp.GetService<CommunicationService>();
commService?.RefreshSiteAddresses();
await AfterConfigurationCommit(sp, () => { commService?.RefreshSiteAddresses(); return Task.CompletedTask; });
await AuditAsync(sp, user, "Create", "Site", site.Id.ToString(), site.Name, site);
return site;
}
@@ -1864,7 +1907,7 @@ public class ManagementActor : ReceiveActor
await repo.UpdateSiteAsync(site);
await repo.SaveChangesAsync();
var commService = sp.GetService<CommunicationService>();
commService?.RefreshSiteAddresses();
await AfterConfigurationCommit(sp, () => { commService?.RefreshSiteAddresses(); return Task.CompletedTask; });
await AuditAsync(sp, user, "Update", "Site", site.Id.ToString(), site.Name, site);
return site;
}
@@ -1880,7 +1923,7 @@ public class ManagementActor : ReceiveActor
await repo.DeleteSiteAsync(cmd.SiteId);
await repo.SaveChangesAsync();
var commService = sp.GetService<CommunicationService>();
commService?.RefreshSiteAddresses();
await AfterConfigurationCommit(sp, () => { commService?.RefreshSiteAddresses(); return Task.CompletedTask; });
// Dispose the deleted site's cached gRPC channels — RemoveSiteAsync is the
// factory's designed site-deletion disposal path and previously had no
@@ -1891,7 +1934,7 @@ public class ManagementActor : ReceiveActor
// the viewer linger (documented acceptance — see Component-Communication.md).
var grpcFactory = sp.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcClientFactory>();
if (grpcFactory is not null && site is not null)
await grpcFactory.RemoveSiteAsync(site.SiteIdentifier);
await AfterConfigurationCommit(sp, () => grpcFactory.RemoveSiteAsync(site.SiteIdentifier));
await AuditAsync(sp, user, "Delete", "Site", cmd.SiteId.ToString(), site?.Name ?? cmd.SiteId.ToString(), null);
return true;
@@ -1904,27 +1947,20 @@ public class ManagementActor : ReceiveActor
return await repo.GetAreaTreeBySiteIdAsync(cmd.SiteId);
}
private static async Task<object?> HandleCreateArea(IServiceProvider sp, CreateAreaCommand cmd, string user)
private static async Task<object?> HandleCreateArea(IServiceProvider sp, CreateAreaCommand cmd, AuthenticatedUser 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;
EnforceSiteScope(user, cmd.SiteId);
var result = await sp.GetRequiredService<AreaService>().CreateAreaAsync(cmd.Name, cmd.SiteId, cmd.ParentAreaId, user.Username);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
private static async Task<object?> HandleDeleteArea(IServiceProvider sp, DeleteAreaCommand cmd, string user)
private static async Task<object?> HandleDeleteArea(IServiceProvider sp, DeleteAreaCommand cmd, AuthenticatedUser 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;
var area = await sp.GetRequiredService<ITemplateEngineRepository>().GetAreaByIdAsync(cmd.AreaId)
?? throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found.");
EnforceSiteScope(user, area.SiteId);
var result = await sp.GetRequiredService<AreaService>().DeleteAreaAsync(cmd.AreaId, user.Username);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
// ========================================================================
@@ -2321,6 +2357,58 @@ public class ManagementActor : ReceiveActor
// Notification handlers
// ========================================================================
private static async Task<object?> HandleRenameNotificationList(IServiceProvider sp, RenameNotificationListCommand cmd, string user)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId)
?? throw new ManagementCommandException("Notification list not found.");
if (string.IsNullOrWhiteSpace(cmd.Name)) throw new ManagementCommandException("Name is required.");
list.Name = cmd.Name.Trim();
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?> HandleAddNotificationRecipient(IServiceProvider sp, AddNotificationRecipientCommand cmd, string user)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId)
?? throw new ManagementCommandException("Notification list not found.");
if (list.Type == NotificationType.Sms ? string.IsNullOrWhiteSpace(cmd.PhoneNumber) : string.IsNullOrWhiteSpace(cmd.EmailAddress))
throw new ManagementCommandException("A contact matching the list's channel is required.");
var recipient = list.Type == NotificationType.Sms
? NotificationRecipient.ForSms(cmd.Name, cmd.PhoneNumber!) : NotificationRecipient.ForEmail(cmd.Name, cmd.EmailAddress!);
recipient.NotificationListId = list.Id;
await repo.AddRecipientAsync(recipient);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "NotificationRecipient", recipient.Id.ToString(), recipient.Name, recipient);
return recipient;
}
private static async Task<object?> HandleDeleteNotificationRecipient(IServiceProvider sp, DeleteNotificationRecipientCommand cmd, string user)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var recipient = await repo.GetRecipientByIdAsync(cmd.RecipientId)
?? throw new ManagementCommandException("Recipient not found.");
await repo.DeleteRecipientAsync(cmd.RecipientId);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Delete", "NotificationRecipient", cmd.RecipientId.ToString(), recipient.Name, null);
return true;
}
private static async Task<object?> HandleDeleteAttributeOverride(IServiceProvider sp, DeleteInstanceAttributeOverrideCommand cmd, AuthenticatedUser user)
{
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
var row = (await repo.GetOverridesByInstanceIdAsync(cmd.InstanceId)).FirstOrDefault(x => x.AttributeName == cmd.AttributeName);
if (row is null) return true;
await repo.DeleteInstanceAttributeOverrideAsync(row.Id);
await repo.SaveChangesAsync();
await AuditAsync(sp, user.Username, "Delete", "InstanceAttributeOverride", row.Id.ToString(), cmd.AttributeName, null);
return true;
}
private static async Task<object?> HandleListNotificationLists(IServiceProvider sp)
{
var repo = sp.GetRequiredService<INotificationRepository>();
@@ -2448,7 +2536,9 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleUpdateSmtpConfig(IServiceProvider sp, UpdateSmtpConfigCommand cmd, string user)
{
var repo = sp.GetRequiredService<INotificationRepository>();
var config = await repo.GetSmtpConfigurationByIdAsync(cmd.SmtpConfigId)
var config = cmd is CreateSmtpConfigCommand
? new Commons.Entities.Notifications.SmtpConfiguration(cmd.Server, cmd.AuthMode, cmd.FromAddress)
: await repo.GetSmtpConfigurationByIdAsync(cmd.SmtpConfigId)
?? throw new ManagementCommandException($"SmtpConfiguration with ID {cmd.SmtpConfigId} not found.");
config.Host = cmd.Server;
config.Port = cmd.Port;
@@ -2466,13 +2556,14 @@ public class ManagementActor : ReceiveActor
// one (null on every pre-EWS row, which means SMTP).
if (cmd.Transport is not null) config.Transport = cmd.Transport;
ValidateSmtpConfigTransportShape(config);
await repo.UpdateSmtpConfigurationAsync(config);
if (cmd is CreateSmtpConfigCommand) await repo.AddSmtpConfigurationAsync(config);
else await repo.UpdateSmtpConfigurationAsync(config);
await repo.SaveChangesAsync();
// Audit the credential-free shape — the *fact of* the change
// (and which non-secret fields hold) is observable; the secret value is
// not persisted to the audit log where OperationalAuditRoles can read it.
var publicShape = SmtpConfigPublicShape(config);
await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, publicShape);
await AuditAsync(sp, user, cmd is CreateSmtpConfigCommand ? "Create" : "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, publicShape);
return publicShape;
}
@@ -2576,7 +2667,9 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<INotificationRepository>();
var configs = await repo.GetAllSmsConfigurationsAsync();
var config = configs.FirstOrDefault(c => c.Id == cmd.SmsConfigId)
var config = cmd is CreateSmsConfigCommand
? new Commons.Entities.Notifications.SmsConfiguration(cmd.AccountSid, cmd.FromNumber)
: configs.FirstOrDefault(c => c.Id == cmd.SmsConfigId)
?? throw new ManagementCommandException($"SmsConfiguration with ID {cmd.SmsConfigId} not found.");
// The AccountSid is interpolated un-escaped into the Twilio request URI
// (SmsNotificationDeliveryAdapter). Reject any value that is not a well-formed
@@ -2597,12 +2690,20 @@ public class ManagementActor : ReceiveActor
// subsequent send. (Contrast SmtpConfiguration.Credentials, which MAY be cleared
// when switching to anonymous SMTP, so that path keeps its null-only guard.)
if (!string.IsNullOrWhiteSpace(cmd.AuthToken)) config.AuthToken = cmd.AuthToken;
await repo.UpdateSmsConfigurationAsync(config);
if (cmd.ConnectionTimeoutSeconds is { } timeout) config.ConnectionTimeoutSeconds = timeout;
if (cmd.MaxRetries is { } retries) config.MaxRetries = retries;
if (cmd.RetryDelay is { } retryDelay) config.RetryDelay = retryDelay;
if (string.IsNullOrWhiteSpace(config.FromNumber) && string.IsNullOrWhiteSpace(config.MessagingServiceSid))
throw new ManagementCommandException("A FromNumber or MessagingServiceSid is required.");
if (cmd is CreateSmsConfigCommand && string.IsNullOrWhiteSpace(config.AuthToken))
throw new ManagementCommandException("AuthToken is required.");
if (cmd is CreateSmsConfigCommand) await repo.AddSmsConfigurationAsync(config);
else await repo.UpdateSmsConfigurationAsync(config);
await repo.SaveChangesAsync();
// Audit the credential-free shape — the AuthToken secret is
// not persisted to the audit log where OperationalAuditRoles can read it.
var publicShape = SmsConfigPublicShape(config);
await AuditAsync(sp, user, "Update", "SmsConfiguration", config.Id.ToString(), config.AccountSid, publicShape);
await AuditAsync(sp, user, cmd is CreateSmsConfigCommand ? "Create" : "Update", "SmsConfiguration", config.Id.ToString(), config.AccountSid, publicShape);
return publicShape;
}
@@ -3414,6 +3515,8 @@ public class ManagementActor : ReceiveActor
{
var repo = sp.GetRequiredService<IExternalSystemRepository>();
var def = new DatabaseConnectionDefinition(cmd.Name, cmd.ConnectionString);
if (cmd.MaxRetries is { } retries) def.MaxRetries = retries;
if (cmd.RetryDelay is { } retryDelay) def.RetryDelay = retryDelay;
await repo.AddDatabaseConnectionAsync(def);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Create", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
@@ -3430,6 +3533,8 @@ public class ManagementActor : ReceiveActor
// secret intact — the connection string is elided from List/Get, so a
// round-trip must not be required to re-send it.
if (cmd.ConnectionString is not null) def.ConnectionString = cmd.ConnectionString;
if (cmd.MaxRetries is { } retries) def.MaxRetries = retries;
if (cmd.RetryDelay is { } retryDelay) def.RetryDelay = retryDelay;
await repo.UpdateDatabaseConnectionAsync(def);
await repo.SaveChangesAsync();
await AuditAsync(sp, user, "Update", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
@@ -3511,7 +3616,7 @@ public class ManagementActor : ReceiveActor
await repo.DeleteApiMethodAsync(cmd.ApiMethodId);
await repo.SaveChangesAsync();
if (method != null)
sp.GetService<InboundAPI.InboundScriptExecutor>()?.InvalidateMethod(method.Name);
await AfterConfigurationCommit(sp, () => { sp.GetService<InboundAPI.InboundScriptExecutor>()?.InvalidateMethod(method.Name); return Task.CompletedTask; });
await AuditAsync(sp, user, "Delete", "ApiMethod", cmd.ApiMethodId.ToString(), method?.Name ?? cmd.ApiMethodId.ToString(), null);
return true;
}
@@ -3532,7 +3637,13 @@ public class ManagementActor : ReceiveActor
var executor = sp.GetService<InboundAPI.InboundScriptExecutor>();
if (executor is null)
return null;
if (executor.CompileAndRegister(method, out var errors))
var compiled = executor.ValidateCompilation(method, out var errors);
var actions = sp.GetService<IConfigurationCommitActions>();
if (actions is not null)
actions.Add(() => { executor.CompileAndRegister(method); return Task.CompletedTask; });
else
executor.CompileAndRegister(method);
if (compiled)
return null;
var detail = string.Join("; ", errors);
@@ -3620,16 +3731,13 @@ public class ManagementActor : ReceiveActor
// Area update handler
// ========================================================================
private static async Task<object?> HandleUpdateArea(IServiceProvider sp, UpdateAreaCommand cmd, string user)
private static async Task<object?> HandleUpdateArea(IServiceProvider sp, UpdateAreaCommand cmd, AuthenticatedUser user)
{
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
var area = await repo.GetAreaByIdAsync(cmd.AreaId)
var area = await sp.GetRequiredService<ITemplateEngineRepository>().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;
EnforceSiteScope(user, area.SiteId);
var result = await sp.GetRequiredService<AreaService>().UpdateAreaAsync(cmd.AreaId, cmd.Name, user.Username);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}
/// <summary>
@@ -3641,10 +3749,12 @@ public class ManagementActor : ReceiveActor
/// response, matching the sibling folder/template move handlers. The service
/// writes the "Move" audit row itself, so this handler does not audit again.
/// </summary>
private static async Task<object?> HandleMoveArea(IServiceProvider sp, MoveAreaCommand cmd, string user)
private static async Task<object?> HandleMoveArea(IServiceProvider sp, MoveAreaCommand cmd, AuthenticatedUser user)
{
var svc = sp.GetRequiredService<AreaService>();
var result = await svc.MoveAreaAsync(cmd.AreaId, cmd.NewParentAreaId, user);
var area = await sp.GetRequiredService<ITemplateEngineRepository>().GetAreaByIdAsync(cmd.AreaId)
?? throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found.");
EnforceSiteScope(user, area.SiteId);
var result = await sp.GetRequiredService<AreaService>().MoveAreaAsync(cmd.AreaId, cmd.NewParentAreaId, user.Username);
return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
}