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:
+8
-1
@@ -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 <@r.EmailAddress></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 <@contact></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>
|
||||||
|
|||||||
+13
@@ -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
|
||||||
|
|||||||
+12
-1
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user