fix(sms): code-review fixes — Admin-gate provider-config updates, guard secret-clear/data-loss, type-aware UI

Findings from the per-module code review of the SMS feature (code-reviews/):

- ManagementService (High): UpdateSmsConfig + UpdateSmtpConfig were Designer-gated
  while both /notifications/{sms,smtp} pages enforce RequireAdmin — a Designer
  blocked in the UI could still rotate a production credential via CLI. Moved both
  to the Administrator arm so the actor gate matches the UI.
- ManagementService (Medium): UpdateSmsConfig treated --auth-token "" as a value,
  silently clearing the stored Twilio token. Guard on IsNullOrWhiteSpace so empty ==
  omitted (SMTP Credentials keeps its null-only guard — empty is valid for no-auth).
- CentralUI (Medium): NotificationLists recipient badge rendered Name <Email>
  unconditionally, showing "Name <>" for SMS lists. Now type-aware (phone for SMS).
- ConfigurationDatabase (Medium): AddSmsNotifications.Down() backfilled NULL emails
  to '' — silent data loss for SMS-only recipients. Added a pre-drop guard that
  refuses rollback while such rows exist.
- NotificationOutbox (Low): SMS body truncation could split a surrogate pair at the
  cap boundary; back off one code unit to stay well-formed.
- Commons (Low): NotificationRecipient public ctor name-guard now matches the
  ForEmail factory (IsNullOrWhiteSpace). Documented SmsConfiguration.MaxRetries/
  RetryDelay as RESERVED (dispatcher reuses the shared SMTP-derived retry policy).
This commit is contained in:
Joseph Doherty
2026-06-19 15:02:02 -04:00
parent d6ead8ae62
commit cd8e4872f6
6 changed files with 77 additions and 11 deletions
@@ -2,6 +2,7 @@
@using ZB.MOM.WW.ScadaBridge.Security @using ZB.MOM.WW.ScadaBridge.Security
@using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications @using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories @using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)] @attribute [Authorize(Policy = AuthorizationPolicies.RequireDesign)]
@inject INotificationRepository NotificationRepository @inject INotificationRepository NotificationRepository
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@@ -67,7 +68,13 @@
{ {
@foreach (var r in recipients) @foreach (var r in recipients)
{ {
<span class="badge bg-light text-dark me-1 mb-1">@r.Name &lt;@r.EmailAddress&gt;</span> @* Type-aware contact: SMS recipients carry a PhoneNumber and a null
EmailAddress, so an email-shaped badge would render "Name <>". Fall
back to whichever contact field is populated. *@
var contact = list.Type == NotificationType.Sms
? r.PhoneNumber
: r.EmailAddress;
<span class="badge bg-light text-dark me-1 mb-1">@r.Name &lt;@contact&gt;</span>
} }
} }
</td> </td>
@@ -20,7 +20,17 @@ public class NotificationRecipient
/// <param name="emailAddress">Email address of the recipient.</param> /// <param name="emailAddress">Email address of the recipient.</param>
public NotificationRecipient(string name, string emailAddress) public NotificationRecipient(string name, string emailAddress)
{ {
Name = name ?? throw new ArgumentNullException(nameof(name)); // Match the ForEmail factory's guard so the invariant ("a recipient always
// has a non-blank display name") holds regardless of construction path. EF
// materializes via the private parameterless ctor + property injection — an
// SMS-only recipient has a null EmailAddress — so this ctor is only reached
// by code that genuinely intends the email path.
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Name must not be empty.", nameof(name));
}
Name = name;
EmailAddress = emailAddress ?? throw new ArgumentNullException(nameof(emailAddress)); EmailAddress = emailAddress ?? throw new ArgumentNullException(nameof(emailAddress));
} }
@@ -17,11 +17,25 @@ public class SmsConfiguration
public string? MessagingServiceSid { get; set; } public string? MessagingServiceSid { get; set; }
/// <summary>Gets or sets the Twilio REST API base URL, or null to use the provider default.</summary> /// <summary>Gets or sets the Twilio REST API base URL, or null to use the provider default.</summary>
public string? ApiBaseUrl { get; set; } public string? ApiBaseUrl { get; set; }
/// <summary>Gets or sets the connection timeout in seconds.</summary> /// <summary>Gets or sets the connection timeout in seconds. Honored per-send by the SMS delivery adapter.</summary>
public int ConnectionTimeoutSeconds { get; set; } public int ConnectionTimeoutSeconds { get; set; }
/// <summary>Gets or sets the maximum number of delivery retries before parking.</summary> /// <summary>
/// Gets or sets the maximum number of delivery retries before parking.
/// <para>
/// RESERVED: the Notification Outbox dispatcher currently derives the retry policy
/// (max-retries + interval) from the central SMTP configuration for <em>all</em>
/// notification types — see the "retry reuses central SMTP max-retry-count and fixed
/// interval" design decision and <c>NotificationOutboxActor.ResolveRetryPolicyAsync</c>.
/// This per-SMS value is persisted/transported for forward-compatibility but is NOT
/// yet read at dispatch time. Honoring it per-type is a deferred enhancement (it would
/// supersede the shared-SMTP-policy decision and is not a silent behavioral change).
/// </para>
/// </summary>
public int MaxRetries { get; set; } public int MaxRetries { get; set; }
/// <summary>Gets or sets the delay between retry attempts.</summary> /// <summary>
/// Gets or sets the delay between retry attempts. RESERVED — see <see cref="MaxRetries"/>:
/// the dispatcher currently uses the shared SMTP-derived retry interval for all types.
/// </summary>
public TimeSpan RetryDelay { get; set; } public TimeSpan RetryDelay { get; set; }
/// <summary> /// <summary>
@@ -51,6 +51,17 @@ IF OBJECT_ID(N'dbo.SmsConfigurations', N'U') IS NULL
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
// ConfigurationDatabase-NNN: refuse to roll back if SMS-only recipients exist.
// Such a row carries a PhoneNumber and a (legitimately) NULL EmailAddress. The
// backfill below sets every NULL EmailAddress to '' so the column can revert to
// NOT NULL — but for an SMS-only recipient that silently destroys its contact
// info (PhoneNumber is also dropped) and leaves an invalid empty-string email.
// This guard MUST run before DROP COLUMN [PhoneNumber], while the column is still
// queryable, so the operator gets a clear error instead of silent data loss.
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM [NotificationRecipients] WHERE [EmailAddress] IS NULL AND [PhoneNumber] IS NOT NULL)
THROW 60000, 'Cannot roll back AddSmsNotifications: SMS-only recipients (PhoneNumber set, EmailAddress NULL) exist. Reassign or delete them before rolling back -- otherwise their PhoneNumber would be dropped and EmailAddress backfilled to an invalid empty string.', 1;");
migrationBuilder.Sql(@" migrationBuilder.Sql(@"
IF OBJECT_ID(N'dbo.SmsConfigurations', N'U') IS NOT NULL IF OBJECT_ID(N'dbo.SmsConfigurations', N'U') IS NOT NULL
DROP TABLE [SmsConfigurations];"); DROP TABLE [SmsConfigurations];");
@@ -61,6 +72,8 @@ IF EXISTS (SELECT 1 FROM sys.columns WHERE Name='PhoneNumber' AND Object_ID=Obje
// Reversing to NOT NULL requires backfilling existing NULLs first, otherwise the // Reversing to NOT NULL requires backfilling existing NULLs first, otherwise the
// ALTER fails. Mirrors EF's generated default ('') for the previously-required column. // ALTER fails. Mirrors EF's generated default ('') for the previously-required column.
// The guard above guarantees any remaining NULL here belongs to a pre-existing email
// recipient that never had an address, not an SMS-only recipient.
migrationBuilder.Sql("UPDATE [NotificationRecipients] SET [EmailAddress] = '' WHERE [EmailAddress] IS NULL;"); migrationBuilder.Sql("UPDATE [NotificationRecipients] SET [EmailAddress] = '' WHERE [EmailAddress] IS NULL;");
migrationBuilder.Sql("ALTER TABLE [NotificationRecipients] ALTER COLUMN [EmailAddress] nvarchar(500) NOT NULL;"); migrationBuilder.Sql("ALTER TABLE [NotificationRecipients] ALTER COLUMN [EmailAddress] nvarchar(500) NOT NULL;");
} }
@@ -174,7 +174,14 @@ public class ManagementActor : ReceiveActor
// should use the REST endpoint; this command is retained for // should use the REST endpoint; this command is retained for
// backward compatibility with the CentralUI Configuration Audit // backward compatibility with the CentralUI Configuration Audit
// Log page (Management-018). // Log page (Management-018).
or QueryAuditLogCommand => Roles.Administrator, or QueryAuditLogCommand
// MgmtSvc-021: SMTP/SMS provider-config updates rotate stored secrets
// (SMTP Credentials, Twilio AuthToken). Both the /notifications/smtp and
// /notifications/sms Central UI pages enforce RequireAdmin, so the actor
// gate must match — otherwise a Designer blocked in the UI could still
// rotate a production credential via the CLI/Management API.
or UpdateSmtpConfigCommand
or UpdateSmsConfigCommand => Roles.Administrator,
// Designer operations // Designer operations
CreateAreaCommand or DeleteAreaCommand CreateAreaCommand or DeleteAreaCommand
@@ -186,8 +193,6 @@ public class ManagementActor : ReceiveActor
or DeleteExternalSystemMethodCommand or DeleteExternalSystemMethodCommand
or CreateNotificationListCommand or UpdateNotificationListCommand or CreateNotificationListCommand or UpdateNotificationListCommand
or DeleteNotificationListCommand or DeleteNotificationListCommand
or UpdateSmtpConfigCommand
or UpdateSmsConfigCommand
or CreateDataConnectionCommand or UpdateDataConnectionCommand or CreateDataConnectionCommand or UpdateDataConnectionCommand
or DeleteDataConnectionCommand or MoveDataConnectionCommand or DeleteDataConnectionCommand or MoveDataConnectionCommand
or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
@@ -1838,7 +1843,7 @@ public class ManagementActor : ReceiveActor
var repo = sp.GetRequiredService<INotificationRepository>(); var repo = sp.GetRequiredService<INotificationRepository>();
var configs = await repo.GetAllSmsConfigurationsAsync(); var configs = await repo.GetAllSmsConfigurationsAsync();
// MgmtSvc-020: project away the AuthToken field — read access to this // MgmtSvc-020: project away the AuthToken field — read access to this
// list is broader than the Designer-gated UpdateSmsConfig path that owns // list is broader than the Admin-only UpdateSmsConfig path that owns
// the secret. // the secret.
return configs.Select(SmsConfigPublicShape).ToList(); return configs.Select(SmsConfigPublicShape).ToList();
} }
@@ -1856,7 +1861,13 @@ public class ManagementActor : ReceiveActor
// existing values intact (non-breaking for callers that do not send them, // existing values intact (non-breaking for callers that do not send them,
// and so the secret AuthToken survives a config edit that does not rotate it). // and so the secret AuthToken survives a config edit that does not rotate it).
if (cmd.ApiBaseUrl is not null) config.ApiBaseUrl = cmd.ApiBaseUrl; if (cmd.ApiBaseUrl is not null) config.ApiBaseUrl = cmd.ApiBaseUrl;
if (cmd.AuthToken is not null) config.AuthToken = cmd.AuthToken; // MgmtSvc-021: treat an empty/whitespace AuthToken as "omitted", not "clear".
// A Twilio Auth Token is always required, so an empty value is never a valid
// rotation — guarding against IsNullOrEmpty (not just null) stops an accidental
// `--auth-token ""` from silently wiping the stored secret and 401-ing every
// 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); await repo.UpdateSmsConfigurationAsync(config);
await repo.SaveChangesAsync(); await repo.SaveChangesAsync();
// MgmtSvc-020: audit the credential-free shape — the AuthToken secret is // MgmtSvc-020: audit the credential-free shape — the AuthToken secret is
@@ -364,7 +364,18 @@ public sealed class SmsNotificationDeliveryAdapter : INotificationDeliveryAdapte
return composed[..max]; return composed[..max];
} }
return string.Concat(composed.AsSpan(0, max - ellipsis.Length), ellipsis); // Cut at (max - ellipsis) UTF-16 code units, backing off by one if that boundary
// would split a surrogate pair (e.g. an emoji in the alarm subject/body). `cut`
// is the index of the first dropped char, so a split is signalled by a high
// surrogate immediately before it; dropping that lone surrogate keeps the body
// well-formed and still within the cap.
var cut = max - ellipsis.Length;
if (char.IsHighSurrogate(composed[cut - 1]))
{
cut--;
}
return string.Concat(composed.AsSpan(0, cut), ellipsis);
} }
/// <summary>The classified outcome of a single per-recipient Twilio attempt.</summary> /// <summary>The classified outcome of a single per-recipient Twilio attempt.</summary>