From 2ef7729bfb68f8b627ddb99357fea7d57d5e8247 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 28 Aug 2026 11:17:09 -0700 Subject: [PATCH] RG-T89 ADP Next Phase, Comm Test Fix --- Core/Resgrid.Config/SecurityConfig.cs | 14 + .../CommunicationTest.ar.resx | 17 +- .../CommunicationTest.de.resx | 17 +- .../CommunicationTest.el.resx | 17 +- .../CommunicationTest.en.resx | 17 +- .../CommunicationTest.es.resx | 17 +- .../CommunicationTest.fr.resx | 17 +- .../CommunicationTest.it.resx | 17 +- .../CommunicationTest.pl.resx | 17 +- .../CommunicationTest.sv.resx | 17 +- .../CommunicationTest.uk.resx | 17 +- Core/Resgrid.Model/CommunicationTestResult.cs | 32 +- .../CommunicationTestResultExtensions.cs | 29 +- Core/Resgrid.Model/ProtectedDataEnvelope.cs | 10 + Core/Resgrid.Model/ProtectedReadResult.cs | 29 + Core/Resgrid.Model/ProtectedWriteResult.cs | 32 + .../Providers/IProtectedDataBrokerClient.cs | 8 +- .../Services/IProtectedReadService.cs | 62 ++ .../Services/IProtectedWriteService.cs | 64 ++ Core/Resgrid.Services/CallsService.cs | 99 +- .../CommunicationTestService.cs | 155 ++- Core/Resgrid.Services/ContactsService.cs | 38 +- .../ProtectedProjectionService.cs | 9 +- Core/Resgrid.Services/ProtectedReadService.cs | 946 ++++++++++++++++++ Core/Resgrid.Services/ServicesModule.cs | 5 + ...130_AddCommunicationTestResultElections.cs | 42 + ...0_AddCommunicationTestResultElectionsPg.cs | 43 + Tests/Resgrid.Tests/Bootstrapper.cs | 21 + .../Services/BrokerOperationServiceTests.cs | 83 ++ .../Services/CallVideoFeedTests.cs | 13 +- .../CallsServiceProtectedWriteTests.cs | 194 ++++ .../Services/CommunicationTestServiceTests.cs | 284 ++++++ .../Services/ProtectedReadServiceTests.cs | 756 ++++++++++++++ .../Web/Services/CallFilesSignedLinkTests.cs | 65 ++ .../Web/Services/CallsControllerTests.cs | 20 +- .../Services/BrokerOperationService.cs | 105 +- Web/Resgrid.Web.Broker/Startup.cs | 4 + .../Resgrid.Web.Eventing.csproj | 1 + Web/Resgrid.Web.Eventing/Startup.cs | 2 + .../Controllers/TwilioController.cs | 6 + .../Controllers/v4/CallFilesController.cs | 155 ++- .../Controllers/v4/CallNotesController.cs | 40 +- .../Controllers/v4/CallsController.cs | 134 ++- .../v4/CommunicationTestResponseController.cs | 4 + .../v4/CommunicationTestsController.cs | 6 +- .../Controllers/v4/ContactsController.cs | 32 +- .../Controllers/v4/FeedsController.cs | 14 +- .../Models/v4/CallFiles/CallFileResult.cs | 12 + .../Models/v4/CallNotes/CallNotesResult.cs | 12 + .../Models/v4/Calls/CallResult.cs | 18 + .../GetTestRunReportResult.cs | 27 +- .../Models/v4/Contacts/ContactNotesResult.cs | 12 + .../Models/v4/Contacts/ContactResult.cs | 15 + .../Resgrid.Web.Services.xml | 114 +++ Web/Resgrid.Web.Services/Startup.cs | 1 - .../User/Controllers/ContactsController.cs | 77 +- .../Controllers/DataProtectionController.cs | 72 +- .../User/Controllers/DispatchController.cs | 130 ++- .../Areas/User/Models/Calls/ViewCallView.cs | 5 + .../User/Models/Contacts/ViewContactView.cs | 3 + .../Views/CommunicationTest/Report.cshtml | 61 +- .../Areas/User/Views/Contacts/View.cshtml | 70 +- .../Areas/User/Views/Dispatch/ViewCall.cshtml | 71 +- .../Dispatch/_ActiveTopCallsPartial.cshtml | 2 +- .../dataprotection/resgrid.adp.reveal.js | 203 ++++ .../Resgrid.Workers.Framework/Bootstrapper.cs | 4 + .../Resgrid.Workers.Framework.csproj | 1 + 67 files changed, 4500 insertions(+), 136 deletions(-) create mode 100644 Core/Resgrid.Model/ProtectedReadResult.cs create mode 100644 Core/Resgrid.Model/ProtectedWriteResult.cs create mode 100644 Core/Resgrid.Model/Services/IProtectedReadService.cs create mode 100644 Core/Resgrid.Model/Services/IProtectedWriteService.cs create mode 100644 Core/Resgrid.Services/ProtectedReadService.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0130_AddCommunicationTestResultElections.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0130_AddCommunicationTestResultElectionsPg.cs create mode 100644 Tests/Resgrid.Tests/Services/CallsServiceProtectedWriteTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs create mode 100644 Tests/Resgrid.Tests/Web/Services/CallFilesSignedLinkTests.cs create mode 100644 Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js diff --git a/Core/Resgrid.Config/SecurityConfig.cs b/Core/Resgrid.Config/SecurityConfig.cs index eee8f771d..8ca7432b2 100644 --- a/Core/Resgrid.Config/SecurityConfig.cs +++ b/Core/Resgrid.Config/SecurityConfig.cs @@ -46,5 +46,19 @@ public static class SecurityConfig /// Increase this value over time as hardware capabilities improve. /// public static int Pbkdf2Iterations = 600000; + + /// + /// Lifetime, in minutes, of signed anonymous file links (CallFiles/GetFile). Links are + /// regenerated on every authenticated GetFilesForCall response, so a short lifetime only + /// bounds how long a leaked/forwarded URL keeps working. Default 24 hours. + /// + public static int SignedFileLinkTtlMinutes = 1440; + + /// + /// Accept legacy signed file links that carry no expiry (issued before expiring links + /// shipped). Leave true through a deployment transition, then flip false so every + /// anonymous file link has a bounded lifetime. + /// + public static bool AllowLegacySignedFileLinks = true; } } diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.ar.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.ar.resx index 7e01f3d9b..79fea2c1c 100644 --- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.ar.resx @@ -1,4 +1,4 @@ - + @@ -76,6 +76,21 @@ هل أنت متأكد من حذف هذا الاختبار؟ + + لم يُرسل (مكتوم) + + + مستوى التوظيف + + + مكتوم + + + مستوى التوظيف لهذا العضو مكتوم في إعدادات القسم، لذلك لم يُرسل إليه أي شيء. + + + لم يتم الاتصال بـ {0} عضو: مستوى التوظيف الخاص بهم مكتوم في إعدادات القسم، لذا لن يصلهم أي إرسال حقيقي أيضًا. + تعريفات الاختبار diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx index 3944a13e2..8c151c3bd 100644 --- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.de.resx @@ -1,4 +1,4 @@ - + @@ -76,6 +76,21 @@ Möchten Sie diesen Test wirklich löschen? + + Nicht gesendet (unterdrückt) + + + Personalstufe + + + Unterdrückt + + + Die Personalstufe dieses Mitglieds ist in Ihren Abteilungseinstellungen stummgeschaltet, daher wurde nichts an ihn gesendet. + + + {0} Mitglied(er) wurden nicht kontaktiert: Ihre Personalstufe ist in Ihren Abteilungseinstellungen stummgeschaltet, eine echte Alarmierung hätte sie ebenfalls nicht erreicht. + Testdefinitionen diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.el.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.el.resx index f32f0ff72..579e6bf7e 100644 --- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.el.resx +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.el.resx @@ -1,4 +1,4 @@ - + @@ -76,6 +76,21 @@ Είστε βέβαιοι ότι θέλετε να διαγράψετε αυτή τη δοκιμή; + + Δεν στάλθηκε (σε σίγαση) + + + Επίπεδο στελέχωσης + + + Σε σίγαση + + + Το επίπεδο στελέχωσης αυτού του μέλους είναι σε σίγαση στις ρυθμίσεις του τμήματος, επομένως δεν του στάλθηκε τίποτα. + + + {0} μέλος/μέλη δεν ειδοποιήθηκαν: το επίπεδο στελέχωσής τους είναι σε σίγαση στις ρυθμίσεις του τμήματος, οπότε ούτε μια πραγματική κλήση θα τους έφτανε. + Ορισμοί Δοκιμών diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx index 7d3dde486..50c360d56 100644 --- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.en.resx @@ -1,4 +1,4 @@ - + @@ -76,6 +76,21 @@ Are you sure you want to delete this test? + + Not Sent (Suppressed) + + + Staffing Level + + + Suppressed + + + This member's staffing level is muted in your department settings, so nothing was sent to them. + + + {0} member(s) were not contacted: their staffing level is muted in your department settings, so a real dispatch would not have reached them either. + Test Definitions diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx index e609226d6..b28586f8f 100644 --- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.es.resx @@ -1,4 +1,4 @@ - + @@ -76,6 +76,21 @@ ¿Seguro que desea eliminar esta prueba? + + No enviado (suprimido) + + + Nivel de personal + + + Suprimido + + + El nivel de personal de este miembro está silenciado en la configuración de su departamento, por lo que no se le envió nada. + + + {0} miembro(s) no fueron contactados: su nivel de personal está silenciado en la configuración de su departamento, por lo que un despacho real tampoco les habría llegado. + Definiciones de prueba diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx index a14d368aa..50371c61c 100644 --- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.fr.resx @@ -1,4 +1,4 @@ - + @@ -76,6 +76,21 @@ Voulez-vous vraiment supprimer ce test ? + + Non envoyé (supprimé) + + + Niveau d'effectif + + + Supprimé + + + Le niveau d'effectif de ce membre est désactivé dans les paramètres de votre département, aucun message ne lui a donc été envoyé. + + + {0} membre(s) n'ont pas été contactés : leur niveau d'effectif est désactivé dans les paramètres de votre département, une véritable alerte ne les aurait pas atteints non plus. + Définitions des tests diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx index 894ee635a..47f3b2573 100644 --- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.it.resx @@ -1,4 +1,4 @@ - + @@ -76,6 +76,21 @@ Vuoi davvero eliminare questo test? + + Non inviato (soppresso) + + + Livello di personale + + + Soppresso + + + Il livello di personale di questo membro è silenziato nelle impostazioni del dipartimento, quindi non gli è stato inviato nulla. + + + {0} membro/i non sono stati contattati: il loro livello di personale è silenziato nelle impostazioni del dipartimento, quindi nemmeno un invio reale li avrebbe raggiunti. + Definizioni dei test diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx index 69fa65485..07696d41c 100644 --- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.pl.resx @@ -1,4 +1,4 @@ - + @@ -76,6 +76,21 @@ Czy na pewno chcesz usunąć ten test? + + Nie wysłano (wyciszone) + + + Poziom obsady + + + Wyciszony + + + Poziom obsady tego członka jest wyciszony w ustawieniach jednostki, więc nic do niego nie wysłano. + + + {0} członk(ów) nie zostało powiadomionych: ich poziom obsady jest wyciszony w ustawieniach jednostki, więc prawdziwe zadysponowanie również by ich nie dotarło. + Definicje testów diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx index 9b09d026f..891df7da6 100644 --- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.sv.resx @@ -1,4 +1,4 @@ - + @@ -76,6 +76,21 @@ Är du säker på att du vill ta bort detta test? + + Ej skickat (tystad) + + + Bemanningsnivå + + + Tystad + + + Den här medlemmens bemanningsnivå är tystad i era avdelningsinställningar, därför skickades ingenting till dem. + + + {0} medlem(mar) kontaktades inte: deras bemanningsnivå är tystad i era avdelningsinställningar, så ett riktigt larm hade inte heller nått dem. + Testdefinitioner diff --git a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx index 93bf8d1f9..4faf61240 100644 --- a/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/CommunicationTest/CommunicationTest.uk.resx @@ -1,4 +1,4 @@ - + @@ -76,6 +76,21 @@ Ви впевнені, що хочете видалити цей тест? + + Не надіслано (приглушено) + + + Рівень укомплектованості + + + Приглушено + + + Рівень укомплектованості цього користувача приглушено в налаштуваннях підрозділу, тому йому нічого не надсилали. + + + {0} користувач(ів) не отримали повідомлень: їхній рівень укомплектованості приглушено в налаштуваннях підрозділу, тож справжнє сповіщення теж би до них не дійшло. + Визначення тестів diff --git a/Core/Resgrid.Model/CommunicationTestResult.cs b/Core/Resgrid.Model/CommunicationTestResult.cs index 245b8640a..b628b5529 100644 --- a/Core/Resgrid.Model/CommunicationTestResult.cs +++ b/Core/Resgrid.Model/CommunicationTestResult.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; @@ -37,6 +37,36 @@ public class CommunicationTestResult : IEntity public int VerificationStatus { get; set; } + /// + /// Whether the member had this channel switched on in their own notification settings when + /// the run was built. Recorded on the row rather than read back off the live profile so the + /// report describes the run as it happened -- a member who turns SMS on the day after a test + /// must not make that run look like it should have texted them. NULL on runs built before + /// this was recorded; the report falls back to the current profile for those. + /// + public bool? ChannelEnabled { get; set; } + + /// + /// The member's staffing level (their last UserState) when the run was built, or NULL when + /// they had never set one. Stored with so a report read + /// months later still shows the level the run actually saw. + /// + public int? StaffingLevel { get; set; } + + /// + /// Display name of as the department had it configured at run + /// time. Snapshotted because a department can rename or delete a custom staffing level. + /// + [MaxLength(50)] + public string StaffingLevelText { get; set; } + + /// + /// Whether the department's Suppress (Mute) Staffing Levels setting muted this member for + /// this run. Suppressed rows are still written and still reported -- the point of the report + /// is to show who a real dispatch would and would not reach -- but nothing is sent to them. + /// + public bool Suppressed { get; set; } + public bool SendAttempted { get; set; } public bool SendSucceeded { get; set; } diff --git a/Core/Resgrid.Model/CommunicationTestResultExtensions.cs b/Core/Resgrid.Model/CommunicationTestResultExtensions.cs index fe11b71b1..231037f0a 100644 --- a/Core/Resgrid.Model/CommunicationTestResultExtensions.cs +++ b/Core/Resgrid.Model/CommunicationTestResultExtensions.cs @@ -1,4 +1,4 @@ -namespace Resgrid.Model +namespace Resgrid.Model { /// /// Presentation helpers for communication test results. These live here rather than in a view so @@ -32,5 +32,32 @@ public static bool HasVerifiableContactMethod(this CommunicationTestResult resul { return result != null && result.Channel != (int)CommunicationTestChannel.Push; } + + /// + /// Label for the staffing level the member was on when the run was built: the name the + /// department had configured at the time, the raw level when a run predates that snapshot or + /// the level has since been deleted, and "-" when the member had never set one. + /// + public static string GetStaffingLevelDisplayText(this CommunicationTestResult result) + { + if (result == null) + return "-"; + + if (!string.IsNullOrWhiteSpace(result.StaffingLevelText)) + return result.StaffingLevelText; + + return result.StaffingLevel.HasValue ? result.StaffingLevel.Value.ToString() : "-"; + } + + /// + /// The member's own on/off election for this channel. Falls back to + /// -- what their profile says right now -- only for runs built before the election was + /// recorded, so an older report reads as unknown-but-plausible rather than claiming every + /// channel in it was switched off. + /// + public static bool GetChannelElection(this CommunicationTestResult result, bool liveValue) + { + return result?.ChannelEnabled ?? liveValue; + } } } diff --git a/Core/Resgrid.Model/ProtectedDataEnvelope.cs b/Core/Resgrid.Model/ProtectedDataEnvelope.cs index 9f7e19a0f..c97559fb7 100644 --- a/Core/Resgrid.Model/ProtectedDataEnvelope.cs +++ b/Core/Resgrid.Model/ProtectedDataEnvelope.cs @@ -30,6 +30,16 @@ public static class ProtectedDataEnvelope /// public const string RedactionValue = "REDACTED"; + /// + /// Display form for surfaces with no reveal pipeline (server-rendered lists, exports, + /// projections): an enveloped value renders as the REDACTED placeholder, anything else + /// passes through. Ciphertext must never reach a page, grid payload, or document. + /// + public static string SafeDisplay(string value) + { + return HasEnvelopePrefix(value) ? RedactionValue : value; + } + /// True when the value starts with either envelope prefix (cheap pre-check). public static bool HasEnvelopePrefix(string value) { diff --git a/Core/Resgrid.Model/ProtectedReadResult.cs b/Core/Resgrid.Model/ProtectedReadResult.cs new file mode 100644 index 000000000..d52a2c17b --- /dev/null +++ b/Core/Resgrid.Model/ProtectedReadResult.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Outcome of resolving one call for an attended read (ADP plan section 7.1). The call the + /// result carries NEVER contains ciphertext: for a protection-enforced department every + /// enveloped field holds either broker-decrypted plaintext (valid grant) or the exact REDACTED + /// placeholder, with the redacted catalog field ids listed so clients render shields and + /// prompt the step-up flow. + /// + public class ProtectedReadResult + { + public Call Call { get; set; } + + /// True when the department is protection-enforced (shield indicator). + public bool IsProtected { get; set; } + + /// Stable catalog field ids ("calls.natureofcall") whose values are REDACTED. + public List RedactedFields { get; set; } = new List(); + + /// + /// Machine-readable reason when fields are redacted: step_up_required, grant_expired, + /// grant_revoked, protected_access_denied, or broker_unavailable. Null when nothing was + /// redacted (unprotected department, or a valid grant revealed everything). + /// + public string ProtectedReason { get; set; } + } +} diff --git a/Core/Resgrid.Model/ProtectedWriteResult.cs b/Core/Resgrid.Model/ProtectedWriteResult.cs new file mode 100644 index 000000000..b34d326c2 --- /dev/null +++ b/Core/Resgrid.Model/ProtectedWriteResult.cs @@ -0,0 +1,32 @@ +namespace Resgrid.Model +{ + /// + /// Outcome of preparing an entity for a protected write (plan sections 3.3, 19.2). Success true + /// means the entity is SAFE TO PERSIST: either the department is not in an encrypt-new-writes + /// state, or every cataloged plaintext value was broker-encrypted in place. Success false means + /// the write MUST NOT proceed — persisting would land plaintext (or destroy data with a + /// round-tripped REDACTED sentinel) in a protected department's rows. + /// + public class ProtectedWriteResult + { + public bool Success { get; set; } + + /// True when the department is in an encrypt-new-writes state. + public bool IsProtected { get; set; } + + /// + /// Value-free reason when blocked: step_up_required, grant_expired, grant_revoked, + /// protected_access_denied, or broker_unavailable. + /// + public string Reason { get; set; } + + /// True when at least one field was encrypted in place — the caller must re-persist. + public bool Changed { get; set; } + + public static ProtectedWriteResult Allowed(bool isProtected = false, bool changed = false) => + new ProtectedWriteResult { Success = true, IsProtected = isProtected, Changed = changed }; + + public static ProtectedWriteResult Blocked(string reason) => + new ProtectedWriteResult { Success = false, IsProtected = true, Reason = reason }; + } +} diff --git a/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs b/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs index 6d30b3ecc..59023eb94 100644 --- a/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs +++ b/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs @@ -41,9 +41,15 @@ public class ProtectedFieldOperationItem /// Stable per-row key used in the envelope AAD (typically the primary key value). public string RowKey { get; set; } - /// Envelope (decrypt) or plaintext (encrypt). Text fields only in v1. + /// + /// Envelope (decrypt) or plaintext (encrypt). Text fields carry the value directly; binary + /// fields (IsBinary) carry base64 of the rgdpb envelope / raw bytes in both directions. + /// public string Value { get; set; } + /// True for rgdpb binary fields — Value is base64 in both directions. + public bool IsBinary { get; set; } + /// Catalog version the envelope's AAD was bound with. public int CatalogVersion { get; set; } } diff --git a/Core/Resgrid.Model/Services/IProtectedReadService.cs b/Core/Resgrid.Model/Services/IProtectedReadService.cs new file mode 100644 index 000000000..e15158fbb --- /dev/null +++ b/Core/Resgrid.Model/Services/IProtectedReadService.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Attended protected-read pipeline for calls (ADP plan sections 3.1 steps 7-9 and 7.1). + /// For a protection-enforced department it validates the caller's Protected Data Grant, sends + /// enveloped field values to the Protected Data Broker in ONE batch, and substitutes plaintext + /// into the call instances; without a valid grant (or on any broker fault) every enveloped + /// value becomes the exact REDACTED placeholder with its catalog field id reported — a client + /// never receives ciphertext as content, and a fault never widens disclosure. Unprotected + /// departments pass through untouched. + /// + /// APP-TIER (web host) ONLY: the implementation depends on the broker client and is registered + /// in web-host composition roots, never in ServicesModule — workers and unattended paths use + /// the safe projections instead. + /// + public interface IProtectedReadService + { + /// + /// Resolves a batch of calls for one attended read. The call instances are the per-request + /// entities the controller fetched (mutated in place; Dapper/cache reads hand each request + /// its own instances). Order of results matches the input order. + /// + Task> ResolveForReadAsync(int departmentId, + IReadOnlyList calls, string grantToken, string userId, CancellationToken cancellationToken = default); + + /// Single-call convenience over the batch overload. + Task ResolveForReadAsync(int departmentId, Call call, + string grantToken, string userId, CancellationToken cancellationToken = default); + + /// + /// Resolves standalone call-note lists (text fields plus the latitude/longitude companion + /// envelopes). Returns one batch-level outcome; the note instances are mutated in place. + /// + Task ResolveNotesForReadAsync(int departmentId, + IReadOnlyList notes, string grantToken, string userId, CancellationToken cancellationToken = default); + + /// + /// Resolves standalone attachment lists. includeData additionally decrypts the rgdpb binary + /// payload (base64 over the broker) — expensive, so only file-serving endpoints opt in; a + /// redacted binary payload becomes null, never ciphertext bytes. + /// + Task ResolveAttachmentsForReadAsync(int departmentId, + IReadOnlyList attachments, string grantToken, string userId, + bool includeData = false, CancellationToken cancellationToken = default); + + /// + /// Resolves contact batches (all 21 cataloged text columns). The enveloped Image blob is + /// always STRIPPED (nulled) on reads — no v4 endpoint serves it, and ciphertext bytes must + /// never ride out through a serializer. + /// + Task ResolveContactsForReadAsync(int departmentId, + IReadOnlyList contacts, string grantToken, string userId, CancellationToken cancellationToken = default); + + /// Resolves standalone contact-note lists (contactnotes.note). + Task ResolveContactNotesForReadAsync(int departmentId, + IReadOnlyList notes, string grantToken, string userId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IProtectedWriteService.cs b/Core/Resgrid.Model/Services/IProtectedWriteService.cs new file mode 100644 index 000000000..89363f272 --- /dev/null +++ b/Core/Resgrid.Model/Services/IProtectedWriteService.cs @@ -0,0 +1,64 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Attended/workload protected-write pipeline (plan sections 3.3, 19.2, 20.3): when a + /// department is in an encrypt-new-writes state, every cataloged plaintext value on the entity + /// is broker-encrypted IN PLACE before the caller persists it — new writes never land plaintext + /// in a protected department's rows, and a failure blocks the write (fail closed) rather than + /// degrading to plaintext. + /// + /// Grant semantics: an ATTENDED caller (workloadCaller false) must hold a currently-valid + /// Protected Data Grant — protected writes require recent MFA. A WORKLOAD caller + /// (workloadCaller true: system API key, text-to-call, workers) encrypts without a grant + /// through the broker's encrypt-only workload lane — encryption discloses nothing, and dispatch + /// intake must never be blocked by a missing step-up. + /// + /// Round-tripped REDACTED sentinels: on edits, a field submitted as the exact REDACTED + /// placeholder is restored from the existing stored value (the client never saw the plaintext, + /// so the sentinel means "unchanged"), never persisted literally. + /// + /// Registered in ServicesModule beside IProtectedReadService: CallsService's write safety net + /// resolves it in every host, so worker/service-internal writers (weather notes, chatbot calls, + /// email import) are covered through the workload lane without per-caller wiring. + /// + public interface IProtectedWriteService + { + /// + /// Cheap pre-persist gate (no broker call): enforcement state plus, for attended callers, + /// grant validity. Lets create endpoints refuse BEFORE inserting the transient plaintext + /// row that two-phase encryption requires (identity PKs only exist after insert, and the + /// row key is an AAD component). + /// + Task PreflightWriteAsync(int departmentId, string grantToken, string userId, + bool workloadCaller, CancellationToken cancellationToken = default); + + /// + /// Prepares a call for persistence. existingCall (the currently stored row) enables + /// REDACTED-sentinel restoration on edits; pass null for creates. + /// + Task PrepareCallWriteAsync(int departmentId, Call call, Call existingCall, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares a call note (text fields; coordinate companions handled here too). + Task PrepareCallNoteWriteAsync(int departmentId, CallNote note, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares a call attachment (text fields, coordinate companions, and the binary payload). + Task PrepareCallAttachmentWriteAsync(int departmentId, CallAttachment attachment, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// + /// Prepares a contact (21 cataloged text fields plus the binary Image payload). + /// existingContact enables REDACTED-sentinel restoration on edits; pass null for creates. + /// + Task PrepareContactWriteAsync(int departmentId, Contact contact, Contact existingContact, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares a contact note (text field). + Task PrepareContactNoteWriteAsync(int departmentId, ContactNote note, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Services/CallsService.cs b/Core/Resgrid.Services/CallsService.cs index feeca3b2b..5a5d9827a 100644 --- a/Core/Resgrid.Services/CallsService.cs +++ b/Core/Resgrid.Services/CallsService.cs @@ -44,6 +44,10 @@ public class CallsService : ICallsService private readonly IIndoorMapService _indoorMapService; private readonly ICallVideoFeedRepository _callVideoFeedRepository; + // Lazy: breaks any construction-time dependency cycle and defers the protected-write graph + // (broker client) until a save actually needs it. + private readonly Lazy _protectedWriteService; + public CallsService(ICallsRepository callsRepository, ICommunicationService communicationService, ICallDispatchesRepository callDispatchesRepository, ICallTypesRepository callTypesRepository, ICallEmailFactory callEmailFactory, ICacheProvider cacheProvider, ICallNotesRepository callNotesRepository, @@ -52,8 +56,10 @@ public CallsService(ICallsRepository callsRepository, ICommunicationService comm IDepartmentCallPriorityRepository departmentCallPriorityRepository, IShortenUrlProvider shortenUrlProvider, ICallProtocolsRepository callProtocolsRepository, IGeoLocationProvider geoLocationProvider, IDepartmentsService departmentsService, ICallReferencesRepository callReferencesRepository, ICallContactsRepository callContactsRepository, - IIndoorMapService indoorMapService, ICallVideoFeedRepository callVideoFeedRepository) + IIndoorMapService indoorMapService, ICallVideoFeedRepository callVideoFeedRepository, + Lazy protectedWriteService) { + _protectedWriteService = protectedWriteService; _callsRepository = callsRepository; _communicationService = communicationService; _callDispatchesRepository = callDispatchesRepository; @@ -150,8 +156,67 @@ public CallsService(ICallsRepository callsRepository, ICommunicationService comm } } + // A round-tripped REDACTED placeholder on an edit (a client that never saw the plaintext + // posting the form back) means "unchanged" — fetch the stored row BEFORE it is + // overwritten so the safety net below can restore the stored envelopes. + Call existingCallForRestore = null; + if (call.CallId > 0 && + Resgrid.Services.ProtectedReadService.CallFieldAccessors.Any(a => a.Value.Get(call) == ProtectedDataEnvelope.RedactionValue)) + existingCallForRestore = await _callsRepository.GetByIdAsync(call.CallId); + var savedCall = await _callsRepository.SaveOrUpdateAsync(call, cancellationToken); + // ADP write safety net (plan 4.2/19.2): whatever path reached this service — API edge, + // chatbot, workers, importers, weather attach — a protected department's cataloged + // plaintext is workload-encrypted before it is left at rest. Attended step-up POLICY is + // enforced at the API edge; this layer only guarantees no plaintext persists, and it + // fails closed by throwing. Already-enveloped fields are skipped, so edge-encrypted + // saves are a no-op here. + var protectedWrite = await _protectedWriteService.Value.PrepareCallWriteAsync(savedCall.DepartmentId, + savedCall, existingCallForRestore, null, null, workloadCaller: true, cancellationToken); + if (existingCallForRestore != null && !protectedWrite.Changed && protectedWrite.Success) + { + // Sentinel restore alone doesn't flip Changed (no broker slots) — persist the + // restored envelopes over the transiently-saved placeholder row. + savedCall = await _callsRepository.SaveOrUpdateAsync(savedCall, cancellationToken); + } + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); call {savedCall.CallId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + savedCall = await _callsRepository.SaveOrUpdateAsync(savedCall, cancellationToken); + + if (protectedWrite.IsProtected) + { + // The repository cascade (HandleChildObjects) also persisted any attached + // Attachments/CallNotes collections — email import builds calls this way — so those + // rows exist with real identity ids (valid AAD rowKeys) but still hold plaintext. + if (savedCall.Attachments != null && savedCall.Attachments.Any()) + { + foreach (var attachment in savedCall.Attachments) + { + var attachmentWrite = await _protectedWriteService.Value.PrepareCallAttachmentWriteAsync(savedCall.DepartmentId, + attachment, null, null, workloadCaller: true, cancellationToken); + if (!attachmentWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({attachmentWrite.Reason}); call attachment {attachment.CallAttachmentId} has transient plaintext pending re-encryption."); + if (attachmentWrite.Changed) + await _callAttachmentRepository.SaveOrUpdateAsync(attachment, cancellationToken); + } + } + + if (savedCall.CallNotes != null && savedCall.CallNotes.Any()) + { + foreach (var note in savedCall.CallNotes) + { + var noteWrite = await _protectedWriteService.Value.PrepareCallNoteWriteAsync(savedCall.DepartmentId, + note, null, null, workloadCaller: true, cancellationToken); + if (!noteWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({noteWrite.Reason}); call note {note.CallNoteId} has transient plaintext pending re-encryption."); + if (noteWrite.Changed) + await _callNotesRepository.SaveOrUpdateAsync(note, cancellationToken); + } + } + } + if (call.References != null && call.References.Any()) { foreach (var reference in call.References) @@ -387,7 +452,21 @@ public async Task GenerateCallFromEmail(int type, CallEmail email, string public async Task SaveCallNoteAsync(CallNote note, CancellationToken cancellationToken = default(CancellationToken)) { - return await _callNotesRepository.SaveOrUpdateAsync(note, cancellationToken); + var saved = await _callNotesRepository.SaveOrUpdateAsync(note, cancellationToken); + + // ADP write safety net — see SaveCallAsync. The department comes through the parent call. + var call = await GetCallByIdAsync(saved.CallId); + if (call != null) + { + var protectedWrite = await _protectedWriteService.Value.PrepareCallNoteWriteAsync(call.DepartmentId, + saved, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); call note {saved.CallNoteId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _callNotesRepository.SaveOrUpdateAsync(saved, cancellationToken); + } + + return saved; } public async Task> GetFlaggedCallNotesByDepartmentIdAsync(int departmentId) @@ -432,7 +511,21 @@ public async Task GetCallAttachmentAsync(int callAttachmentId) public async Task SaveCallAttachmentAsync(CallAttachment attachment, CancellationToken cancellationToken = default(CancellationToken)) { - return await _callAttachmentRepository.SaveOrUpdateAsync(attachment, cancellationToken); + var saved = await _callAttachmentRepository.SaveOrUpdateAsync(attachment, cancellationToken); + + // ADP write safety net — see SaveCallAsync. + var call = await GetCallByIdAsync(saved.CallId); + if (call != null) + { + var protectedWrite = await _protectedWriteService.Value.PrepareCallAttachmentWriteAsync(call.DepartmentId, + saved, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); call attachment {saved.CallAttachmentId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _callAttachmentRepository.SaveOrUpdateAsync(saved, cancellationToken); + } + + return saved; } public async Task MarkCallDispatchesAsSentAsync(int callId, List usersToMark) diff --git a/Core/Resgrid.Services/CommunicationTestService.cs b/Core/Resgrid.Services/CommunicationTestService.cs index 7583d0d70..3eaf0a555 100644 --- a/Core/Resgrid.Services/CommunicationTestService.cs +++ b/Core/Resgrid.Services/CommunicationTestService.cs @@ -25,6 +25,13 @@ public class CommunicationTestService : ICommunicationTestService /// private static readonly TimeSpan RecoveryGracePeriod = TimeSpan.FromMinutes(30); + /// + /// Width of CommunicationTestResults.StaffingLevelText. A department can name a custom + /// staffing level anything it likes, so the snapshot is trimmed rather than left to fail the + /// insert and take the whole run down with it. + /// + private const int StaffingLevelTextMaxLength = 50; + private readonly ICommunicationTestRepository _communicationTestRepository; private readonly ICommunicationTestRunRepository _communicationTestRunRepository; private readonly ICommunicationTestResultRepository _communicationTestResultRepository; @@ -34,6 +41,8 @@ public class CommunicationTestService : ICommunicationTestService private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IPersonnelRolesService _personnelRolesService; private readonly IDepartmentSettingsService _departmentSettingsService; + private readonly IUserStateService _userStateService; + private readonly ICustomStateService _customStateService; private readonly ISmsService _smsService; private readonly IEmailService _emailService; private readonly IPushService _pushService; @@ -52,6 +61,8 @@ public CommunicationTestService( IDepartmentGroupsService departmentGroupsService, IPersonnelRolesService personnelRolesService, IDepartmentSettingsService departmentSettingsService, + IUserStateService userStateService, + ICustomStateService customStateService, ISmsService smsService, IEmailService emailService, IPushService pushService, @@ -69,6 +80,8 @@ public CommunicationTestService( _departmentGroupsService = departmentGroupsService; _personnelRolesService = personnelRolesService; _departmentSettingsService = departmentSettingsService; + _userStateService = userStateService; + _customStateService = customStateService; _smsService = smsService; _emailService = emailService; _pushService = pushService; @@ -416,6 +429,14 @@ public async Task BuildRunResultsAsync(Guid communicationT if (targetedUserIds != null) members = members.Where(m => targetedUserIds.Contains(m.UserId)).ToList(); + // A communication test only proves something if it behaves like the real thing. The + // department's Suppress (Mute) Staffing Levels setting is what keeps a dispatch away from + // someone who is off duty, so a test that ignored it would both report a delivery rate no + // real dispatch could reach and page every off-duty member of the department to do it. + var suppressInfo = await _departmentSettingsService.GetDepartmentStaffingSuppressInfoAsync(departmentId); + var latestStates = BuildLatestStateLookup(await _userStateService.GetLatestStatesForDepartmentAsync(departmentId)); + var staffingNames = await BuildStaffingNameLookupAsync(departmentId); + int totalUsersTested = 0; foreach (var member in members) @@ -423,9 +444,18 @@ public async Task BuildRunResultsAsync(Guid communicationT profiles.TryGetValue(member.UserId, out var profile); bool userHasResults = false; + // Snapshotted onto every row this member gets: read back off the live profile and + // department settings, a report opened next month would describe today's configuration + // instead of the run it is supposed to be a record of. + latestStates.TryGetValue(member.UserId, out var memberState); + int? staffingLevel = memberState?.State; + var staffingLevelText = ResolveStaffingLevelText(staffingLevel, staffingNames); + var suppressed = IsStaffingSuppressed(suppressInfo, staffingLevel); + if (test.TestEmail) { var emailVerified = profile?.EmailVerified; + var emailEnabled = IsEmailEnabled(profile); var result = new CommunicationTestResult { CommunicationTestRunId = run.CommunicationTestRunId, @@ -434,9 +464,14 @@ public async Task BuildRunResultsAsync(Guid communicationT Channel = (int)CommunicationTestChannel.Email, ContactValue = profile?.MembershipEmail, VerificationStatus = (int)emailVerified.ToVerificationStatus(), - SendAttempted = emailVerified.IsContactMethodAllowedForSending() + ChannelEnabled = emailEnabled, + StaffingLevel = staffingLevel, + StaffingLevelText = staffingLevelText, + Suppressed = suppressed, + SendAttempted = !suppressed + && emailVerified.IsContactMethodAllowedForSending() && !string.IsNullOrWhiteSpace(profile?.MembershipEmail) - && IsEmailEnabled(profile), + && emailEnabled, SendSucceeded = false, Responded = false, ResponseToken = Guid.NewGuid().ToString("N") @@ -453,6 +488,7 @@ public async Task BuildRunResultsAsync(Guid communicationT if (profile != null && profile.MobileCarrier > 0) carrierName = ((MobileCarriers)profile.MobileCarrier).GetDescription(); + var smsEnabled = IsSmsEnabled(profile); var result = new CommunicationTestResult { CommunicationTestRunId = run.CommunicationTestRunId, @@ -462,9 +498,14 @@ public async Task BuildRunResultsAsync(Guid communicationT ContactValue = profile?.GetPhoneNumber(), ContactCarrier = carrierName, VerificationStatus = (int)mobileVerified.ToVerificationStatus(), - SendAttempted = mobileVerified.IsContactMethodAllowedForSending() + ChannelEnabled = smsEnabled, + StaffingLevel = staffingLevel, + StaffingLevelText = staffingLevelText, + Suppressed = suppressed, + SendAttempted = !suppressed + && mobileVerified.IsContactMethodAllowedForSending() && !string.IsNullOrWhiteSpace(profile?.GetPhoneNumber()) - && IsSmsEnabled(profile), + && smsEnabled, SendSucceeded = false, Responded = false, ResponseToken = Guid.NewGuid().ToString("N") @@ -482,6 +523,7 @@ public async Task BuildRunResultsAsync(Guid communicationT var voiceNumber = useHome ? profile?.GetHomePhoneNumber() : profile?.GetPhoneNumber(); var voiceVerified = useHome ? profile?.HomeNumberVerified : profile?.MobileNumberVerified; + var voiceEnabled = IsVoiceEnabled(profile); var result = new CommunicationTestResult { @@ -491,9 +533,14 @@ public async Task BuildRunResultsAsync(Guid communicationT Channel = (int)CommunicationTestChannel.Voice, ContactValue = voiceNumber, VerificationStatus = (int)voiceVerified.ToVerificationStatus(), - SendAttempted = voiceVerified.IsContactMethodAllowedForSending() + ChannelEnabled = voiceEnabled, + StaffingLevel = staffingLevel, + StaffingLevelText = staffingLevelText, + Suppressed = suppressed, + SendAttempted = !suppressed + && voiceVerified.IsContactMethodAllowedForSending() && !string.IsNullOrWhiteSpace(voiceNumber) - && profile != null && profile.VoiceForCall, + && voiceEnabled, SendSucceeded = false, Responded = false, ResponseToken = Guid.NewGuid().ToString("N") @@ -505,6 +552,7 @@ public async Task BuildRunResultsAsync(Guid communicationT if (test.TestPush) { + var pushEnabled = IsPushEnabled(profile); var result = new CommunicationTestResult { CommunicationTestRunId = run.CommunicationTestRunId, @@ -512,9 +560,13 @@ public async Task BuildRunResultsAsync(Guid communicationT UserId = member.UserId, Channel = (int)CommunicationTestChannel.Push, VerificationStatus = (int)ContactVerificationStatus.Verified, + ChannelEnabled = pushEnabled, + StaffingLevel = staffingLevel, + StaffingLevelText = staffingLevelText, + Suppressed = suppressed, // The push service silently drops a notification when this opt-in is off, so // gate here rather than reporting an attempt that never leaves the process. - SendAttempted = profile != null && profile.SendNotificationPush, + SendAttempted = !suppressed && pushEnabled, SendSucceeded = false, Responded = false, ResponseToken = Guid.NewGuid().ToString("N") @@ -573,7 +625,10 @@ public async Task DeliverRunAsync(Guid communicationTestRunId, Cancellation foreach (var result in results) { - if (!result.SendAttempted || result.SentOn.HasValue) + // Suppressed is checked as well as SendAttempted: the builder already clears + // SendAttempted for a muted member, and messaging someone the department has muted is + // the one failure this feature must not have, so it is gated on both. + if (!result.SendAttempted || result.Suppressed || result.SentOn.HasValue) continue; profiles.TryGetValue(result.UserId, out var profile); @@ -778,6 +833,90 @@ private static bool IsSmsEnabled(UserProfile profile) private static bool IsEmailEnabled(UserProfile profile) => profile != null && (profile.SendEmail || profile.SendMessageEmail || profile.SendNotificationEmail); + private static bool IsVoiceEnabled(UserProfile profile) + => profile != null && profile.VoiceForCall; + + private static bool IsPushEnabled(UserProfile profile) + => profile != null && profile.SendNotificationPush; + + /// + /// Newest staffing state per member. Folds on the timestamp rather than trusting the list to + /// already hold one row per user, so a department that comes back with more than one still + /// resolves to the level the run should see. + /// + private static Dictionary BuildLatestStateLookup(List states) + { + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (states == null) + return lookup; + + foreach (var state in states) + { + if (state == null || string.IsNullOrWhiteSpace(state.UserId)) + continue; + + if (!lookup.TryGetValue(state.UserId, out var existing) || state.Timestamp > existing.Timestamp) + lookup[state.UserId] = state; + } + + return lookup; + } + + /// + /// Staffing level id to display name for a department, using its configured staffing levels + /// and falling back to the Resgrid defaults. Built once per run: resolving per member would be + /// a lookup per person, and a level renamed mid-run would make two rows of the same report + /// disagree about what the same number means. + /// + private async Task> BuildStaffingNameLookupAsync(int departmentId) + { + var names = new Dictionary(); + + var details = await _customStateService.GetCustomPersonnelStaffingsOrDefaultsAsync(departmentId); + if (details == null) + return names; + + foreach (var detail in details) + { + if (detail == null || string.IsNullOrWhiteSpace(detail.ButtonText)) + continue; + + names[detail.CustomStateDetailId] = detail.ButtonText; + } + + return names; + } + + /// + /// Display name to record for a staffing level. A level the department has since deleted -- + /// or one that was never in its configured set -- still has to say something, and the raw + /// number is the only honest thing left to show. + /// + private static string ResolveStaffingLevelText(int? staffingLevel, Dictionary staffingNames) + { + if (!staffingLevel.HasValue) + return null; + + if (staffingNames != null && staffingNames.TryGetValue(staffingLevel.Value, out var name) && !string.IsNullOrWhiteSpace(name)) + return name.Length > StaffingLevelTextMaxLength ? name.Substring(0, StaffingLevelTextMaxLength) : name; + + return staffingLevel.Value.ToString(); + } + + /// + /// Whether the department's Suppress (Mute) Staffing Levels setting mutes a member sitting on + /// this staffing level. Mirrors CommunicationService.CanSendToUser, including its treatment of + /// a member with no recorded state: there is no level to match against, so they are not muted. + /// + private static bool IsStaffingSuppressed(DepartmentSuppressStaffingInfo suppressInfo, int? staffingLevel) + { + if (suppressInfo == null || !suppressInfo.EnableSupressStaffing || suppressInfo.StaffingLevelsToSupress == null) + return false; + + return staffingLevel.HasValue && suppressInfo.StaffingLevelsToSupress.Contains(staffingLevel.Value); + } + #endregion Delivery public async Task> GetRunsByTestIdAsync(Guid communicationTestId) diff --git a/Core/Resgrid.Services/ContactsService.cs b/Core/Resgrid.Services/ContactsService.cs index 174b933e2..a76a84b40 100644 --- a/Core/Resgrid.Services/ContactsService.cs +++ b/Core/Resgrid.Services/ContactsService.cs @@ -20,10 +20,12 @@ public class ContactsService : IContactsService private readonly IContactNoteTypesRepository _contactNoteTypesRepository; private readonly IContactAssociationsRepository _contactAssociationsRepository; private readonly IEventAggregator _eventAggregator; + private readonly Lazy _protectedWriteService; public ContactsService(IContactsRepository contactsRepository, IContactNotesRepository contactNotesRepository, IContactCategoryRepository contactCategoryRepository, IContactNoteTypesRepository contactNoteTypesRepository, - IContactAssociationsRepository contactAssociationsRepository, IEventAggregator eventAggregator) + IContactAssociationsRepository contactAssociationsRepository, IEventAggregator eventAggregator, + Lazy protectedWriteService) { _contactsRepository = contactsRepository; _contactCategoryRepository = contactCategoryRepository; @@ -31,6 +33,7 @@ public ContactsService(IContactsRepository contactsRepository, IContactNotesRepo _contactNoteTypesRepository = contactNoteTypesRepository; _contactAssociationsRepository = contactAssociationsRepository; _eventAggregator = eventAggregator; + _protectedWriteService = protectedWriteService; } public async Task> GetAllContactsForDepartmentAsync(int departmentId) @@ -73,7 +76,27 @@ public async Task> GetContactCategoriesForDepartmentAsync( public async Task SaveContactAsync(Contact contact, CancellationToken cancellationToken = default(CancellationToken)) { - return await _contactsRepository.SaveOrUpdateAsync(contact, cancellationToken); + // Round-tripped REDACTED placeholder on an edit means "unchanged" — fetch the stored row + // before it is overwritten so the safety net can restore the stored envelopes. + Contact existingContactForRestore = null; + if (!string.IsNullOrWhiteSpace(contact.ContactId) && + ProtectedReadService.ContactFieldAccessors.Any(a => a.Value.Get(contact) == ProtectedDataEnvelope.RedactionValue)) + existingContactForRestore = await _contactsRepository.GetByIdAsync(contact.ContactId); + + var savedContact = await _contactsRepository.SaveOrUpdateAsync(contact, cancellationToken); + + // ADP write safety net (plan 4.2/19.2): mirrors CallsService — post-save so the row's id + // (a repository-assigned guid on creates) is a valid AAD rowKey; already-enveloped and + // REDACTED-sentinel values were handled by the caller/Prepare, so this is a no-op for + // edge-encrypted saves. Fails closed by throwing. + var protectedWrite = await _protectedWriteService.Value.PrepareContactWriteAsync(savedContact.DepartmentId, + savedContact, existingContactForRestore, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); contact {savedContact.ContactId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed || (existingContactForRestore != null && protectedWrite.Success)) + savedContact = await _contactsRepository.SaveOrUpdateAsync(savedContact, cancellationToken); + + return savedContact; } public async Task> GetContactsByCategoryIdAsync(int departmentId, string categoryId) @@ -165,7 +188,16 @@ public async Task DoesContactNoteTypeAlreadyExistAsync(int departmentId, s public async Task SaveContactNoteAsync(ContactNote note, CancellationToken cancellationToken = default(CancellationToken)) { - return await _contactNotesRepository.SaveOrUpdateAsync(note, cancellationToken); + var savedNote = await _contactNotesRepository.SaveOrUpdateAsync(note, cancellationToken); + + var protectedWrite = await _protectedWriteService.Value.PrepareContactNoteWriteAsync(savedNote.DepartmentId, + savedNote, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); contact note {savedNote.ContactNoteId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + savedNote = await _contactNotesRepository.SaveOrUpdateAsync(savedNote, cancellationToken); + + return savedNote; } public async Task DeleteContactAsync(string contactId, string userId, int departmentId, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)) diff --git a/Core/Resgrid.Services/ProtectedProjectionService.cs b/Core/Resgrid.Services/ProtectedProjectionService.cs index afe325f5e..fcc8b0d41 100644 --- a/Core/Resgrid.Services/ProtectedProjectionService.cs +++ b/Core/Resgrid.Services/ProtectedProjectionService.cs @@ -118,7 +118,14 @@ public async Task BuildNotificationSafeCallAsync(int departmentId, Call ca if (!enforced) return call; - if (await ChannelAllowsProtectedContentAsync(departmentId, channel)) + // AllowProtectedContent lets the original call through — but only while its fields are + // actually plaintext. Post-migration the entity carries rgdp envelopes and notification + // hosts cannot decrypt (no broker/grant), so an enveloped call degrades to the sanitized + // clone: a carrier must never receive ciphertext as message content. + if (await ChannelAllowsProtectedContentAsync(departmentId, channel) && + !ProtectedDataEnvelope.HasEnvelopePrefix(call.Name) && + !ProtectedDataEnvelope.HasEnvelopePrefix(call.NatureOfCall) && + !ProtectedDataEnvelope.HasEnvelopePrefix(call.Address)) return call; // Sanitized clone: only the allowlisted system-generated call number, priority/color, diff --git a/Core/Resgrid.Services/ProtectedReadService.cs b/Core/Resgrid.Services/ProtectedReadService.cs new file mode 100644 index 000000000..acae29983 --- /dev/null +++ b/Core/Resgrid.Services/ProtectedReadService.cs @@ -0,0 +1,946 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// Attended protected-read pipeline for calls and their children. See + /// for the contract. The field sets mirror the + /// catalog-v1 bindings (AdpTableBindings) — the same lists the migration engine envelopes — + /// through static accessor maps so a binding change without a matching accessor fails loudly in + /// the parity test, not silently in redaction. Attachment binary payloads (rgdpb) ride the + /// broker as base64 and only when a file-serving endpoint opts in. Registered ONLY in web-host + /// composition roots (it depends on the broker client). + /// + public class ProtectedReadService : IProtectedReadService, IProtectedWriteService + { + /// Catalog field id -> (getter, setter) for every cataloged Calls text column. + /// Public so the parity test can pin it against AdpTableBindings.V1. + public static readonly IReadOnlyDictionary Get, Action Set)> CallFieldAccessors = + new Dictionary, Action)> + { + ["calls.name"] = (c => c.Name, (c, v) => c.Name = v), + ["calls.type"] = (c => c.Type, (c, v) => c.Type = v), + ["calls.natureofcall"] = (c => c.NatureOfCall, (c, v) => c.NatureOfCall = v), + ["calls.notes"] = (c => c.Notes, (c, v) => c.Notes = v), + ["calls.completednotes"] = (c => c.CompletedNotes, (c, v) => c.CompletedNotes = v), + ["calls.address"] = (c => c.Address, (c, v) => c.Address = v), + ["calls.geolocationdata"] = (c => c.GeoLocationData, (c, v) => c.GeoLocationData = v), + ["calls.w3w"] = (c => c.W3W, (c, v) => c.W3W = v), + ["calls.contactname"] = (c => c.ContactName, (c, v) => c.ContactName = v), + ["calls.contactnumber"] = (c => c.ContactNumber, (c, v) => c.ContactNumber = v), + ["calls.sourceidentifier"] = (c => c.SourceIdentifier, (c, v) => c.SourceIdentifier = v), + ["calls.incidentnumber"] = (c => c.IncidentNumber, (c, v) => c.IncidentNumber = v), + ["calls.externalidentifier"] = (c => c.ExternalIdentifier, (c, v) => c.ExternalIdentifier = v), + ["calls.referencenumber"] = (c => c.ReferenceNumber, (c, v) => c.ReferenceNumber = v), + ["calls.callformdata"] = (c => c.CallFormData, (c, v) => c.CallFormData = v), + ["calls.deletedreason"] = (c => c.DeletedReason, (c, v) => c.DeletedReason = v) + }; + + /// CallNotes text columns (parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> NoteFieldAccessors = + new Dictionary, Action)> + { + ["callnotes.note"] = (n => n.Note, (n, v) => n.Note = v), + ["callnotes.flaggedreason"] = (n => n.FlaggedReason, (n, v) => n.FlaggedReason = v) + }; + + /// CallNotes companion columns: envelope property + typed setter (parity-pinned). + public static readonly IReadOnlyDictionary GetEnvelope, Action SetTyped)> NoteCompanionAccessors = + new Dictionary, Action)> + { + ["callnotes.latitude"] = (n => n.ProtectedLatitudeEnvelope, (n, v) => n.Latitude = v), + ["callnotes.longitude"] = (n => n.ProtectedLongitudeEnvelope, (n, v) => n.Longitude = v) + }; + + /// CallAttachments text columns (parity-pinned; Data is the separate binary field). + public static readonly IReadOnlyDictionary Get, Action Set)> AttachmentFieldAccessors = + new Dictionary, Action)> + { + ["callattachments.name"] = (a => a.Name, (a, v) => a.Name = v), + ["callattachments.filename"] = (a => a.FileName, (a, v) => a.FileName = v), + ["callattachments.flaggedreason"] = (a => a.FlaggedReason, (a, v) => a.FlaggedReason = v) + }; + + /// CallAttachments companion columns (parity-pinned). + public static readonly IReadOnlyDictionary GetEnvelope, Action SetTyped)> AttachmentCompanionAccessors = + new Dictionary, Action)> + { + ["callattachments.latitude"] = (a => a.ProtectedLatitudeEnvelope, (a, v) => a.Latitude = v), + ["callattachments.longitude"] = (a => a.ProtectedLongitudeEnvelope, (a, v) => a.Longitude = v) + }; + + /// The rgdpb binary attachment payload field id. + public const string AttachmentDataFieldId = "callattachments.data"; + + /// Contacts text columns (parity-pinned; Image is the separate binary field). + public static readonly IReadOnlyDictionary Get, Action Set)> ContactFieldAccessors = + new Dictionary, Action)> + { + ["contacts.firstname"] = (c => c.FirstName, (c, v) => c.FirstName = v), + ["contacts.middlename"] = (c => c.MiddleName, (c, v) => c.MiddleName = v), + ["contacts.lastname"] = (c => c.LastName, (c, v) => c.LastName = v), + ["contacts.othername"] = (c => c.OtherName, (c, v) => c.OtherName = v), + ["contacts.companyname"] = (c => c.CompanyName, (c, v) => c.CompanyName = v), + ["contacts.email"] = (c => c.Email, (c, v) => c.Email = v), + ["contacts.countryissuedidnumber"] = (c => c.CountryIssuedIdNumber, (c, v) => c.CountryIssuedIdNumber = v), + ["contacts.countryidname"] = (c => c.CountryIdName, (c, v) => c.CountryIdName = v), + ["contacts.stateidnumber"] = (c => c.StateIdNumber, (c, v) => c.StateIdNumber = v), + ["contacts.stateidname"] = (c => c.StateIdName, (c, v) => c.StateIdName = v), + ["contacts.stateidcountryname"] = (c => c.StateIdCountryName, (c, v) => c.StateIdCountryName = v), + ["contacts.homephonenumber"] = (c => c.HomePhoneNumber, (c, v) => c.HomePhoneNumber = v), + ["contacts.cellphonenumber"] = (c => c.CellPhoneNumber, (c, v) => c.CellPhoneNumber = v), + ["contacts.faxphonenumber"] = (c => c.FaxPhoneNumber, (c, v) => c.FaxPhoneNumber = v), + ["contacts.officephonenumber"] = (c => c.OfficePhoneNumber, (c, v) => c.OfficePhoneNumber = v), + ["contacts.description"] = (c => c.Description, (c, v) => c.Description = v), + ["contacts.otherinfo"] = (c => c.OtherInfo, (c, v) => c.OtherInfo = v), + ["contacts.locationgpscoordinates"] = (c => c.LocationGpsCoordinates, (c, v) => c.LocationGpsCoordinates = v), + ["contacts.entrancegpscoordinates"] = (c => c.EntranceGpsCoordinates, (c, v) => c.EntranceGpsCoordinates = v), + ["contacts.exitgpscoordinates"] = (c => c.ExitGpsCoordinates, (c, v) => c.ExitGpsCoordinates = v), + ["contacts.locationgeofence"] = (c => c.LocationGeofence, (c, v) => c.LocationGeofence = v) + }; + + /// The rgdpb binary contact image field id (stripped on reads, never served via v4). + public const string ContactImageFieldId = "contacts.image"; + + /// ContactNotes text columns (parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> ContactNoteFieldAccessors = + new Dictionary, Action)> + { + ["contactnotes.note"] = (n => n.Note, (n, v) => n.Note = v) + }; + + private static readonly byte[] BinaryPrefixBytes = Encoding.ASCII.GetBytes(ProtectedDataEnvelope.BinaryPrefix); + + /// One protected value wired to its reveal/redact actions on the owning entity. + private sealed class Slot + { + public string FieldId; + public string RowKey; + public bool IsBinary; + public string WireValue; + public ProtectedReadResult Owner; + public Action Reveal; + public Action Redact; + } + + private readonly IDepartmentDataProtectionService _dataProtectionService; + private readonly IProtectedDataGrantService _grantService; + private readonly IProtectedDataBrokerClient _brokerClient; + + public ProtectedReadService(IDepartmentDataProtectionService dataProtectionService, + IProtectedDataGrantService grantService, IProtectedDataBrokerClient brokerClient) + { + _dataProtectionService = dataProtectionService; + _grantService = grantService; + _brokerClient = brokerClient; + } + + public async Task ResolveForReadAsync(int departmentId, Call call, + string grantToken, string userId, CancellationToken cancellationToken = default) + { + var results = await ResolveForReadAsync(departmentId, + call == null ? Array.Empty() : new[] { call }, grantToken, userId, cancellationToken); + return results.Count > 0 ? results[0] : new ProtectedReadResult { Call = null }; + } + + public async Task> ResolveForReadAsync(int departmentId, + IReadOnlyList calls, string grantToken, string userId, CancellationToken cancellationToken = default) + { + calls ??= Array.Empty(); + var results = calls.Select(c => new ProtectedReadResult { Call = c }).ToList(); + if (results.Count == 0) + return results; + + var slots = new List(); + foreach (var result in results) + { + CollectCallSlots(result, slots); + + // Children ride the same batch when the controller populated them. The binary + // attachment payload is deliberately excluded here — only file-serving endpoints + // opt into it via ResolveAttachmentsForReadAsync(includeData: true). + if (result.Call.CallNotes != null) + foreach (var note in result.Call.CallNotes.Where(n => n != null)) + CollectNoteSlots(result, note, slots); + + if (result.Call.Attachments != null) + foreach (var attachment in result.Call.Attachments.Where(a => a != null)) + CollectAttachmentSlots(result, attachment, slots, includeData: false); + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, results, slots, cancellationToken); + return results; + } + + public async Task ResolveNotesForReadAsync(int departmentId, + IReadOnlyList notes, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var note in (notes ?? Array.Empty()).Where(n => n != null)) + CollectNoteSlots(result, note, slots); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveAttachmentsForReadAsync(int departmentId, + IReadOnlyList attachments, string grantToken, string userId, + bool includeData = false, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var attachment in (attachments ?? Array.Empty()).Where(a => a != null)) + CollectAttachmentSlots(result, attachment, slots, includeData); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveContactsForReadAsync(int departmentId, + IReadOnlyList contacts, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var contact in (contacts ?? Array.Empty()).Where(c => c != null)) + CollectContactSlots(result, contact, slots); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveContactNotesForReadAsync(int departmentId, + IReadOnlyList notes, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var note in (notes ?? Array.Empty()).Where(n => n != null)) + { + var rowKey = note.ContactNoteId; + foreach (var accessor in ContactNoteFieldAccessors) + { + var value = accessor.Value.Get(note); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + var target = note; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = result, + Reveal = plaintext => set(target, plaintext), + Redact = () => set(target, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + private static void CollectContactSlots(ProtectedReadResult owner, Contact contact, List slots) + { + var rowKey = contact.ContactId; + foreach (var accessor in ContactFieldAccessors) + { + var value = accessor.Value.Get(contact); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + var target = contact; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = owner, + Reveal = plaintext => set(target, plaintext), + Redact = () => set(target, ProtectedDataEnvelope.RedactionValue) + }); + } + + // The enveloped image blob is stripped unconditionally: no v4 endpoint serves it, and + // ciphertext bytes must never ride out through a serializer. + if (IsBinaryEnveloped(contact.Image)) + contact.Image = null; + } + + /// + /// Clone of ONLY the cataloged call fields, taken before an edit overwrites them — the + /// REDACTED-sentinel restore source for PrepareCallWriteAsync. + /// + public static Call SnapshotCatalogedCallFields(Call call) + { + var snapshot = new Call(); + foreach (var accessor in CallFieldAccessors) + accessor.Value.Set(snapshot, accessor.Value.Get(call)); + return snapshot; + } + + // ── protected writes (IProtectedWriteService) ──────────────────────────── + + /// One plaintext value queued for broker encryption, with its apply-back action. + private sealed class WriteSlot + { + public string FieldId; + public string RowKey; + public bool IsBinary; + public string WireValue; + public Action Apply; + } + + public async Task PreflightWriteAsync(int departmentId, string grantToken, string userId, + bool workloadCaller, CancellationToken cancellationToken = default) + { + bool shouldEncrypt; + try + { + shouldEncrypt = await _dataProtectionService.ShouldEncryptNewWritesAsync(departmentId); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Protection-state lookup failed for department {departmentId}; blocking the protected write defensively."); + return ProtectedWriteResult.Blocked("broker_unavailable"); + } + + if (!shouldEncrypt) + return ProtectedWriteResult.Allowed(); + + if (workloadCaller) + return ProtectedWriteResult.Allowed(isProtected: true); + + if (string.IsNullOrWhiteSpace(grantToken)) + return ProtectedWriteResult.Blocked("step_up_required"); + + var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId); + var outcome = _grantService.ValidateGrant(grantToken, departmentId, policy?.PolicyEpoch ?? 0, + ProtectedDataGrantScopes.Write, out var grant); + if (outcome != ProtectedDataGrantValidationOutcome.Valid) + return ProtectedWriteResult.Blocked(outcome switch + { + ProtectedDataGrantValidationOutcome.Expired => "grant_expired", + ProtectedDataGrantValidationOutcome.EpochRevoked => "grant_revoked", + _ => "step_up_required" + }); + if (!string.Equals(grant.UserId, userId, StringComparison.OrdinalIgnoreCase)) + return ProtectedWriteResult.Blocked("protected_access_denied"); + + return ProtectedWriteResult.Allowed(isProtected: true); + } + + public async Task PrepareCallWriteAsync(int departmentId, Call call, Call existingCall, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (call == null) + return ProtectedWriteResult.Allowed(); + + var slots = new List(); + var rowKey = call.CallId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in CallFieldAccessors) + { + var value = accessor.Value.Get(call); + + // Round-tripped REDACTED sentinel on an edit means "unchanged": restore the stored + // value (usually an envelope) instead of persisting the literal placeholder. Without + // a stored row to restore from the sentinel is still never encrypted — enveloping the + // placeholder would silently destroy the original. + if (value == ProtectedDataEnvelope.RedactionValue) + { + if (existingCall != null) + accessor.Value.Set(call, accessor.Value.Get(existingCall)); + continue; + } + + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Apply = envelope => set(call, envelope) + }); + } + + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + } + + public async Task PrepareCallNoteWriteAsync(int departmentId, CallNote note, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (note == null) + return ProtectedWriteResult.Allowed(); + + var slots = new List(); + var rowKey = note.CallNoteId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in NoteFieldAccessors) + { + var value = accessor.Value.Get(note); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(note, envelope) }); + } + + // Companion columns: the typed coordinate moves into its envelope column and the typed + // column is nulled — the migration engine's exact write shape (plan 22.3). + if (note.Latitude.HasValue) + slots.Add(new WriteSlot + { + FieldId = "callnotes.latitude", + RowKey = rowKey, + WireValue = note.Latitude.Value.ToString(CultureInfo.InvariantCulture), + Apply = envelope => { note.ProtectedLatitudeEnvelope = envelope; note.Latitude = null; } + }); + if (note.Longitude.HasValue) + slots.Add(new WriteSlot + { + FieldId = "callnotes.longitude", + RowKey = rowKey, + WireValue = note.Longitude.Value.ToString(CultureInfo.InvariantCulture), + Apply = envelope => { note.ProtectedLongitudeEnvelope = envelope; note.Longitude = null; } + }); + + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => note.IsProtected = true, cancellationToken); + } + + public async Task PrepareCallAttachmentWriteAsync(int departmentId, CallAttachment attachment, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (attachment == null) + return ProtectedWriteResult.Allowed(); + + var slots = new List(); + var rowKey = attachment.CallAttachmentId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in AttachmentFieldAccessors) + { + var value = accessor.Value.Get(attachment); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(attachment, envelope) }); + } + + if (attachment.Latitude.HasValue) + slots.Add(new WriteSlot + { + FieldId = "callattachments.latitude", + RowKey = rowKey, + WireValue = attachment.Latitude.Value.ToString(CultureInfo.InvariantCulture), + Apply = envelope => { attachment.ProtectedLatitudeEnvelope = envelope; attachment.Latitude = null; } + }); + if (attachment.Longitude.HasValue) + slots.Add(new WriteSlot + { + FieldId = "callattachments.longitude", + RowKey = rowKey, + WireValue = attachment.Longitude.Value.ToString(CultureInfo.InvariantCulture), + Apply = envelope => { attachment.ProtectedLongitudeEnvelope = envelope; attachment.Longitude = null; } + }); + + if (attachment.Data != null && attachment.Data.Length > 0 && !IsBinaryEnveloped(attachment.Data)) + slots.Add(new WriteSlot + { + FieldId = AttachmentDataFieldId, + RowKey = rowKey, + IsBinary = true, + WireValue = Convert.ToBase64String(attachment.Data), + Apply = envelopeBase64 => attachment.Data = Convert.FromBase64String(envelopeBase64) + }); + + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => attachment.IsProtected = true, cancellationToken); + } + + public async Task PrepareContactWriteAsync(int departmentId, Contact contact, Contact existingContact, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (contact == null) + return ProtectedWriteResult.Allowed(); + + var slots = new List(); + var rowKey = contact.ContactId; + foreach (var accessor in ContactFieldAccessors) + { + var value = accessor.Value.Get(contact); + + if (value == ProtectedDataEnvelope.RedactionValue) + { + if (existingContact != null) + accessor.Value.Set(contact, accessor.Value.Get(existingContact)); + continue; + } + + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(contact, envelope) }); + } + + if (contact.Image != null && contact.Image.Length > 0 && !IsBinaryEnveloped(contact.Image)) + slots.Add(new WriteSlot + { + FieldId = ContactImageFieldId, + RowKey = rowKey, + IsBinary = true, + WireValue = Convert.ToBase64String(contact.Image), + Apply = envelopeBase64 => contact.Image = Convert.FromBase64String(envelopeBase64) + }); + + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + } + + public async Task PrepareContactNoteWriteAsync(int departmentId, ContactNote note, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (note == null) + return ProtectedWriteResult.Allowed(); + + var slots = new List(); + var rowKey = note.ContactNoteId; + foreach (var accessor in ContactNoteFieldAccessors) + { + var value = accessor.Value.Get(note); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(note, envelope) }); + } + + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + } + + /// + /// REDACTED-sentinel restore source for PrepareContactWriteAsync (mirrors + /// SnapshotCatalogedCallFields for the MVC contact edit surface). + /// + public static Contact SnapshotCatalogedContactFields(Contact contact) + { + var snapshot = new Contact(); + foreach (var accessor in ContactFieldAccessors) + accessor.Value.Set(snapshot, accessor.Value.Get(contact)); + return snapshot; + } + + /// + /// Shared write core: enforcement check, attended-grant gate, ONE broker encrypt batch, and + /// ALL-OR-NOTHING application — any failure blocks the write; plaintext never persists in a + /// protected department's rows. + /// + private async Task EncryptSlotsAsync(int departmentId, string grantToken, string userId, + bool workloadCaller, List slots, Action markProtected, CancellationToken cancellationToken) + { + bool shouldEncrypt; + try + { + shouldEncrypt = await _dataProtectionService.ShouldEncryptNewWritesAsync(departmentId); + } + catch (Exception ex) + { + // Unknown protection state on a WRITE fails closed: persisting plaintext into a + // possibly-protected department is the one unrecoverable direction. + Logging.LogException(ex, $"Protection-state lookup failed for department {departmentId}; blocking the protected write defensively."); + return ProtectedWriteResult.Blocked("broker_unavailable"); + } + + if (!shouldEncrypt) + return ProtectedWriteResult.Allowed(); + + if (slots.Count == 0) + { + markProtected?.Invoke(); + return ProtectedWriteResult.Allowed(isProtected: true); + } + + // Attended callers need a currently-valid grant (RequireStepUpForProtectedWrites, plan + // 3.3). Workload callers use the broker's encrypt-only lane — no grant, no disclosure. + if (!workloadCaller) + { + var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId); + var outcome = _grantService.ValidateGrant(grantToken, departmentId, policy?.PolicyEpoch ?? 0, + ProtectedDataGrantScopes.Write, out var grant); + if (string.IsNullOrWhiteSpace(grantToken)) + return ProtectedWriteResult.Blocked("step_up_required"); + if (outcome != ProtectedDataGrantValidationOutcome.Valid) + return ProtectedWriteResult.Blocked(outcome switch + { + ProtectedDataGrantValidationOutcome.Expired => "grant_expired", + ProtectedDataGrantValidationOutcome.EpochRevoked => "grant_revoked", + _ => "step_up_required" + }); + if (!string.Equals(grant.UserId, userId, StringComparison.OrdinalIgnoreCase)) + return ProtectedWriteResult.Blocked("protected_access_denied"); + } + + var policyRow = await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId); + var catalogVersion = policyRow?.CatalogVersion ?? 0; + + var items = slots.Select(s => new ProtectedFieldOperationItem + { + FieldId = s.FieldId, + RowKey = s.RowKey, + Value = s.WireValue, + IsBinary = s.IsBinary, + CatalogVersion = catalogVersion + }).ToList(); + + ProtectedDataBrokerResult brokerResult; + try + { + brokerResult = await _brokerClient.EncryptAsync(departmentId, workloadCaller ? null : grantToken, + Guid.NewGuid().ToString("N"), items, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Protected write broker call failed for department {departmentId}; blocking the write."); + brokerResult = null; + } + + if (brokerResult == null || !brokerResult.Success) + return ProtectedWriteResult.Blocked(brokerResult?.ErrorCode switch + { + "grant_expired" => "grant_expired", + "grant_revoked" => "grant_revoked", + "grant_invalid" => "step_up_required", + _ => "broker_unavailable" + }); + + var encrypted = brokerResult.Items + .Where(i => i != null && i.FieldId != null && i.RowKey != null) + .GroupBy(i => (i.RowKey, i.FieldId)) + .ToDictionary(g => g.Key, g => g.First()); + + // ALL items must have encrypted cleanly before ANY is applied. + foreach (var slot in slots) + { + if (!encrypted.TryGetValue((slot.RowKey, slot.FieldId), out var item) || + item.ErrorCode != null || string.IsNullOrEmpty(item.Value)) + return ProtectedWriteResult.Blocked("broker_unavailable"); + } + + foreach (var slot in slots) + slot.Apply(encrypted[(slot.RowKey, slot.FieldId)].Value); + + markProtected?.Invoke(); + return ProtectedWriteResult.Allowed(isProtected: true, changed: true); + } + + /// True when the blob starts with the rgdpb envelope prefix (format check only). + public static bool IsBinaryEnveloped(byte[] value) + { + if (value == null || value.Length < BinaryPrefixBytes.Length) + return false; + + for (var i = 0; i < BinaryPrefixBytes.Length; i++) + { + if (value[i] != BinaryPrefixBytes[i]) + return false; + } + + return true; + } + + // ── slot collection ────────────────────────────────────────────────────── + + private static void CollectCallSlots(ProtectedReadResult owner, List slots) + { + var call = owner.Call; + var rowKey = call.CallId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in CallFieldAccessors) + { + var value = accessor.Value.Get(call); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = owner, + Reveal = plaintext => set(call, plaintext), + Redact = () => set(call, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + private static void CollectNoteSlots(ProtectedReadResult owner, CallNote note, List slots) + { + var rowKey = note.CallNoteId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in NoteFieldAccessors) + { + var value = accessor.Value.Get(note); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = owner, + Reveal = plaintext => set(note, plaintext), + Redact = () => set(note, ProtectedDataEnvelope.RedactionValue) + }); + } + + foreach (var accessor in NoteCompanionAccessors) + { + var envelope = accessor.Value.GetEnvelope(note); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(envelope)) + continue; + + var setTyped = accessor.Value.SetTyped; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = envelope, + Owner = owner, + // Companion reveal: the envelope held the invariant string of the typed value; + // an unparseable payload stays concealed (typed column remains null). + Reveal = plaintext => setTyped(note, + decimal.TryParse(plaintext, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed) ? parsed : null), + Redact = () => setTyped(note, null) + }); + } + } + + private static void CollectAttachmentSlots(ProtectedReadResult owner, CallAttachment attachment, + List slots, bool includeData) + { + var rowKey = attachment.CallAttachmentId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in AttachmentFieldAccessors) + { + var value = accessor.Value.Get(attachment); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = owner, + Reveal = plaintext => set(attachment, plaintext), + Redact = () => set(attachment, ProtectedDataEnvelope.RedactionValue) + }); + } + + foreach (var accessor in AttachmentCompanionAccessors) + { + var envelope = accessor.Value.GetEnvelope(attachment); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(envelope)) + continue; + + var setTyped = accessor.Value.SetTyped; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = envelope, + Owner = owner, + Reveal = plaintext => setTyped(attachment, + decimal.TryParse(plaintext, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed) ? parsed : null), + Redact = () => setTyped(attachment, null) + }); + } + + if (includeData && IsBinaryEnveloped(attachment.Data)) + { + slots.Add(new Slot + { + FieldId = AttachmentDataFieldId, + RowKey = rowKey, + IsBinary = true, + WireValue = Convert.ToBase64String(attachment.Data), + Owner = owner, + Reveal = base64 => attachment.Data = Convert.FromBase64String(base64), + // A concealed binary payload is NULL — ciphertext bytes are never served. + Redact = () => attachment.Data = null + }); + } + else if (!includeData && IsBinaryEnveloped(attachment.Data)) + { + // Metadata-only resolution: strip the ciphertext bytes so a serializer can never + // carry them out; the file endpoints re-fetch and opt into decryption. + attachment.Data = null; + } + } + + // ── shared resolution core ─────────────────────────────────────────────── + + private async Task ResolveSlotsAsync(int departmentId, string grantToken, string userId, + List results, List slots, CancellationToken cancellationToken) + { + bool enforced; + try + { + enforced = await _dataProtectionService.IsProtectionEnforcedAsync(departmentId); + } + catch (Exception ex) + { + // Unknown protection state must not leak: treat as enforced with no grant. + Logging.LogException(ex, $"Protection-state lookup failed for department {departmentId}; redacting protected reads defensively."); + RedactSlots(slots, "protected_access_denied"); + foreach (var result in results) + result.IsProtected = true; + return; + } + + if (!enforced) + return; + + foreach (var result in results) + result.IsProtected = true; + + if (slots.Count == 0) + return; + + var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId); + var currentEpoch = policy?.PolicyEpoch ?? 0; + var catalogVersion = policy?.CatalogVersion ?? 0; + + // One grant validation per batch, bound to this user and department at the current + // policy epoch. Anything but Valid redacts with a machine-readable reason the clients + // map onto the step-up flow. + string redactionReason; + if (string.IsNullOrWhiteSpace(grantToken)) + { + redactionReason = "step_up_required"; + } + else + { + var outcome = _grantService.ValidateGrant(grantToken, departmentId, currentEpoch, + ProtectedDataGrantScopes.Read, out var grant); + redactionReason = outcome switch + { + ProtectedDataGrantValidationOutcome.Valid when + string.Equals(grant.UserId, userId, StringComparison.OrdinalIgnoreCase) => null, + ProtectedDataGrantValidationOutcome.Valid => "protected_access_denied", + ProtectedDataGrantValidationOutcome.Expired => "grant_expired", + ProtectedDataGrantValidationOutcome.EpochRevoked => "grant_revoked", + _ => "step_up_required" + }; + } + + if (redactionReason != null) + { + RedactSlots(slots, redactionReason); + return; + } + + var items = slots.Select(s => new ProtectedFieldOperationItem + { + FieldId = s.FieldId, + RowKey = s.RowKey, + Value = s.WireValue, + IsBinary = s.IsBinary, + CatalogVersion = catalogVersion + }).ToList(); + + ProtectedDataBrokerResult brokerResult; + try + { + brokerResult = await _brokerClient.DecryptAsync(departmentId, grantToken, + Guid.NewGuid().ToString("N"), items, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Protected read broker call failed for department {departmentId}; redacting."); + brokerResult = null; + } + + if (brokerResult == null || !brokerResult.Success) + { + RedactSlots(slots, brokerResult?.ErrorCode switch + { + "grant_expired" => "grant_expired", + "grant_revoked" => "grant_revoked", + "grant_invalid" => "step_up_required", + _ => "broker_unavailable" + }); + return; + } + + var decrypted = brokerResult.Items + .Where(i => i != null && i.FieldId != null && i.RowKey != null) + .GroupBy(i => (i.RowKey, i.FieldId)) + .ToDictionary(g => g.Key, g => g.First()); + + foreach (var slot in slots) + { + if (decrypted.TryGetValue((slot.RowKey, slot.FieldId), out var item) && + item.ErrorCode == null && item.Value != null) + { + try + { + slot.Reveal(item.Value); + continue; + } + catch (FormatException) + { + // Fall through to redaction: a malformed reveal payload stays concealed. + } + } + + // Per-item broker fault (corrupt envelope, unknown key version): that one field + // stays concealed; the rest of the batch reads normally. + slot.Redact(); + RecordRedaction(slot, "broker_unavailable"); + } + } + + /// Redacts every slot and records the reason on each affected result. + private static void RedactSlots(List slots, string reason) + { + foreach (var slot in slots) + { + slot.Redact(); + RecordRedaction(slot, reason); + } + } + + private static void RecordRedaction(Slot slot, string reason) + { + if (!slot.Owner.RedactedFields.Contains(slot.FieldId)) + slot.Owner.RedactedFields.Add(slot.FieldId); + slot.Owner.ProtectedReason ??= reason; + } + } +} diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 64ef1f767..2ca4e0ff9 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -170,6 +170,11 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); + // Attended protected reads + the write safety net. Requires IProtectedDataBrokerClient, + // so every composition root that loads this module must also load + // ProtectedDataBrokerClientModule (client only — no key material). + builder.RegisterType() + .As().As().InstancePerLifetimeScope(); // The real engine is registered everywhere but only functions where a real key wrapping // provider resolves (LocalDev for synthetic testing; the broker host in production). On diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0130_AddCommunicationTestResultElections.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0130_AddCommunicationTestResultElections.cs new file mode 100644 index 000000000..2c1d8efb9 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0130_AddCommunicationTestResultElections.cs @@ -0,0 +1,42 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Records, per communication test result row, the member's own notification election for that + /// channel, the staffing level they were on when the run was built, and whether the department's + /// Suppress (Mute) Staffing Levels setting muted them. All three are snapshots: a report read + /// months later has to describe the run as it happened, not as the current profile and current + /// department settings would have it. + /// + /// ChannelEnabled is nullable on purpose -- runs built before this migration have no election + /// recorded, and the report falls back to the live profile for those rather than claiming every + /// historical channel was switched off. + /// + [Migration(130)] + public class M0130_AddCommunicationTestResultElections : Migration + { + public override void Up() + { + if (!Schema.Table("CommunicationTestResults").Column("ChannelEnabled").Exists()) + { + Alter.Table("CommunicationTestResults") + .AddColumn("ChannelEnabled").AsBoolean().Nullable() + .AddColumn("StaffingLevel").AsInt32().Nullable() + .AddColumn("StaffingLevelText").AsString(50).Nullable() + .AddColumn("Suppressed").AsBoolean().NotNullable().WithDefaultValue(false); + } + } + + public override void Down() + { + if (Schema.Table("CommunicationTestResults").Column("ChannelEnabled").Exists()) + { + Delete.Column("Suppressed").FromTable("CommunicationTestResults"); + Delete.Column("StaffingLevelText").FromTable("CommunicationTestResults"); + Delete.Column("StaffingLevel").FromTable("CommunicationTestResults"); + Delete.Column("ChannelEnabled").FromTable("CommunicationTestResults"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0130_AddCommunicationTestResultElectionsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0130_AddCommunicationTestResultElectionsPg.cs new file mode 100644 index 000000000..27ecaedb6 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0130_AddCommunicationTestResultElectionsPg.cs @@ -0,0 +1,43 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Records, per communication test result row, the member's own notification election for that + /// channel, the staffing level they were on when the run was built, and whether the department's + /// Suppress (Mute) Staffing Levels setting muted them. All three are snapshots: a report read + /// months later has to describe the run as it happened, not as the current profile and current + /// department settings would have it. + /// + /// channelenabled is nullable on purpose -- runs built before this migration have no election + /// recorded, and the report falls back to the live profile for those rather than claiming every + /// historical channel was switched off. staffingleveltext is citext to match the other + /// communication test text columns (M0062). + /// + [Migration(130)] + public class M0130_AddCommunicationTestResultElectionsPg : Migration + { + public override void Up() + { + if (!Schema.Table("communicationtestresults").Column("channelenabled").Exists()) + { + Alter.Table("communicationtestresults") + .AddColumn("channelenabled").AsBoolean().Nullable() + .AddColumn("staffinglevel").AsInt32().Nullable() + .AddColumn("staffingleveltext").AsCustom("citext").Nullable() + .AddColumn("suppressed").AsBoolean().NotNullable().WithDefaultValue(false); + } + } + + public override void Down() + { + if (Schema.Table("communicationtestresults").Column("channelenabled").Exists()) + { + Delete.Column("suppressed").FromTable("communicationtestresults"); + Delete.Column("staffingleveltext").FromTable("communicationtestresults"); + Delete.Column("staffinglevel").FromTable("communicationtestresults"); + Delete.Column("channelenabled").FromTable("communicationtestresults"); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Bootstrapper.cs b/Tests/Resgrid.Tests/Bootstrapper.cs index 85baa7468..e06508711 100644 --- a/Tests/Resgrid.Tests/Bootstrapper.cs +++ b/Tests/Resgrid.Tests/Bootstrapper.cs @@ -1,6 +1,7 @@ using Autofac; using Autofac.Extras.CommonServiceLocator; using CommonServiceLocator; +using Moq; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; using Resgrid.Providers.AddressVerification; @@ -84,6 +85,26 @@ public static void Initialize() builder.RegisterInstance(new Moq.Mock().Object) .As(); + // CallsService's write safety net resolves IProtectedWriteService lazily. The real + // ProtectedReadService needs the broker client (not registered here), and a LOOSE mock + // would return a null Task from Prepare* (NRE at the await) — so the stub is set up to + // answer every call with Allowed(): departments in this container are never protected. + var protectedWriteStub = new Moq.Mock(); + protectedWriteStub.Setup(x => x.PreflightWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) + .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); + protectedWriteStub.Setup(x => x.PrepareCallWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) + .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); + protectedWriteStub.Setup(x => x.PrepareCallNoteWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) + .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); + protectedWriteStub.Setup(x => x.PrepareCallAttachmentWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) + .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); + protectedWriteStub.Setup(x => x.PrepareContactWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) + .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); + protectedWriteStub.Setup(x => x.PrepareContactNoteWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) + .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); + builder.RegisterInstance(protectedWriteStub.Object) + .As(); + // The real FeatureToggleService's repository graph is not in the testing data module; // the protection service consumes it only for the enrollment admission gate, which no // container-driven test exercises. Loose mock: every flag reads as absent (fail closed). diff --git a/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs b/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs index 86166d66f..d3d60a336 100644 --- a/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs @@ -141,6 +141,89 @@ public async Task Encrypt_then_decrypt_roundtrips_with_full_aad_binding() decrypted.Items[0].Value.Should().Be("Structure fire, 3 Main St"); } + [Test] + public async Task Workload_lane_encrypts_without_a_grant_but_decrypt_still_requires_one() + { + // Encrypt-only workload lane (plan 3.4): no grant, past the workload-key middleware — + // allowed, because encryption discloses nothing. Decrypt without a grant stays refused. + var encrypted = await _service.EncryptAsync(Request(null, "req-w1", Item("dispatch note")), CancellationToken.None); + encrypted.Success.Should().BeTrue(); + encrypted.Items[0].ErrorCode.Should().BeNull(); + ProtectedDataEnvelope.IsEnveloped(encrypted.Items[0].Value).Should().BeTrue(); + + var decrypted = await _service.DecryptAsync(Request(null, "req-w2", Item(encrypted.Items[0].Value)), CancellationToken.None); + decrypted.Success.Should().BeFalse(); + decrypted.ErrorCode.Should().Be("grant_invalid"); + decrypted.Items.Should().BeEmpty(); + } + + [Test] + public async Task Workload_lane_still_validates_a_presented_grant() + { + // A stale grant cannot be laundered through the encrypt path just because the lane + // would have allowed no grant at all. + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)) + .ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = DeptId, PolicyEpoch = Epoch + 1 }); + + var result = await _service.EncryptAsync(Request(IssueGrantToken(), "req-w3", Item("value")), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be("grant_revoked"); + } + + [Test] + public async Task Binary_encrypt_then_decrypt_roundtrips_over_base64() + { + var token = IssueGrantToken(); + var plaintext = new byte[] { 1, 2, 3, 4, 5 }; + + var encrypted = await _service.EncryptAsync(Request(token, "req-b1", new ProtectedFieldOperationItem + { + FieldId = "callattachments.data", + RowKey = "9", + Value = Convert.ToBase64String(plaintext), + IsBinary = true, + CatalogVersion = 1 + }), CancellationToken.None); + + encrypted.Success.Should().BeTrue(); + encrypted.Items[0].ErrorCode.Should().BeNull(); + var envelopeBytes = Convert.FromBase64String(encrypted.Items[0].Value); + System.Text.Encoding.ASCII.GetString(envelopeBytes, 0, 6).Should().Be("rgdpb:"); + + var decrypted = await _service.DecryptAsync(Request(token, "req-b2", new ProtectedFieldOperationItem + { + FieldId = "callattachments.data", + RowKey = "9", + Value = encrypted.Items[0].Value, + IsBinary = true, + CatalogVersion = 1 + }), CancellationToken.None); + + decrypted.Success.Should().BeTrue(); + decrypted.Items[0].ErrorCode.Should().BeNull(); + Convert.FromBase64String(decrypted.Items[0].Value).Should().BeEquivalentTo(plaintext); + } + + [Test] + public async Task Binary_decrypt_of_a_non_enveloped_blob_reports_not_enveloped() + { + var token = IssueGrantToken(); + + var result = await _service.DecryptAsync(Request(token, "req-b3", new ProtectedFieldOperationItem + { + FieldId = "callattachments.data", + RowKey = "9", + Value = Convert.ToBase64String(new byte[] { 7, 7, 7 }), + IsBinary = true, + CatalogVersion = 1 + }), CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Items[0].ErrorCode.Should().Be("not_enveloped"); + result.Items[0].Value.Should().BeNull(); + } + [Test] public async Task Moved_ciphertext_fails_decrypt_per_item_without_failing_the_request() { diff --git a/Tests/Resgrid.Tests/Services/CallVideoFeedTests.cs b/Tests/Resgrid.Tests/Services/CallVideoFeedTests.cs index 2c96f226d..710a7cddf 100644 --- a/Tests/Resgrid.Tests/Services/CallVideoFeedTests.cs +++ b/Tests/Resgrid.Tests/Services/CallVideoFeedTests.cs @@ -37,6 +37,7 @@ public class CallVideoFeedTests private Mock _callContactsRepo; private Mock _indoorMapService; private Mock _callVideoFeedRepo; + private Mock _protectedWriteService; private CallsService _service; [SetUp] @@ -62,6 +63,15 @@ public void SetUp() _callContactsRepo = new Mock(); _indoorMapService = new Mock(); _callVideoFeedRepo = new Mock(); + // Loose-mock Prepare* would return a null Task (NRE at the await in the write safety + // net); every call answers Allowed() — video-feed tests never touch a protected dept. + _protectedWriteService = new Mock(); + _protectedWriteService.Setup(x => x.PrepareCallWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + _protectedWriteService.Setup(x => x.PrepareCallNoteWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + _protectedWriteService.Setup(x => x.PrepareCallAttachmentWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); _service = new CallsService( _callsRepo.Object, _communicationService.Object, _callDispatchesRepo.Object, @@ -70,7 +80,8 @@ public void SetUp() _callDispatchUnitRepo.Object, _callDispatchRoleRepo.Object, _callPriorityRepo.Object, _shortenUrlProvider.Object, _callProtocolsRepo.Object, _geoLocationProvider.Object, _departmentsService.Object, _callReferencesRepo.Object, _callContactsRepo.Object, - _indoorMapService.Object, _callVideoFeedRepo.Object); + _indoorMapService.Object, _callVideoFeedRepo.Object, + new Lazy(() => _protectedWriteService.Object)); } [Test] diff --git a/Tests/Resgrid.Tests/Services/CallsServiceProtectedWriteTests.cs b/Tests/Resgrid.Tests/Services/CallsServiceProtectedWriteTests.cs new file mode 100644 index 000000000..ea5c43af6 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CallsServiceProtectedWriteTests.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Pins the ADP write safety net in CallsService.SaveCallAsync (plan 4.2/19.2): every path + /// that persists a call through the service — including cascade-saved Attachments/CallNotes + /// collections, which is how email import lands children — leaves no cataloged plaintext in a + /// protected department's rows, and a blocked write throws instead of degrading. + /// + [TestFixture] + public class CallsServiceProtectedWriteTests + { + private Mock _callsRepo; + private Mock _callNotesRepo; + private Mock _callAttachmentRepo; + private Mock _protectedWriteService; + private CallsService _service; + + [SetUp] + public void SetUp() + { + _callsRepo = new Mock(); + _callNotesRepo = new Mock(); + _callAttachmentRepo = new Mock(); + _protectedWriteService = new Mock(); + + _callsRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Call c, CancellationToken _, bool __) => c); + _callNotesRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CallNote n, CancellationToken _, bool __) => n); + _callAttachmentRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CallAttachment a, CancellationToken _, bool __) => a); + + _protectedWriteService.Setup(x => x.PrepareCallWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + _protectedWriteService.Setup(x => x.PrepareCallNoteWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + _protectedWriteService.Setup(x => x.PrepareCallAttachmentWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + + _service = new CallsService( + _callsRepo.Object, Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + _callNotesRepo.Object, _callAttachmentRepo.Object, Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), + new Lazy(() => _protectedWriteService.Object)); + } + + private static Call BuildCall() => new Call + { + CallId = 42, + DepartmentId = 10, + Number = "26-100", + Name = "Structure Fire", + LoggedOn = DateTime.UtcNow + }; + + [Test] + public async Task SaveCallAsync_UnprotectedDepartment_DoesNotTouchCascadeChildren() + { + var call = BuildCall(); + call.Attachments = new List { new CallAttachment { CallAttachmentId = 7, FileName = "scene.jpg" } }; + call.CallNotes = new List { new CallNote { CallNoteId = 8, Note = "note" } }; + + var result = await _service.SaveCallAsync(call); + + result.Should().NotBeNull(); + _protectedWriteService.Verify(x => x.PrepareCallWriteAsync(10, It.IsAny(), null, null, null, true, It.IsAny()), Times.Once); + _protectedWriteService.Verify(x => x.PrepareCallAttachmentWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _protectedWriteService.Verify(x => x.PrepareCallNoteWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _callsRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task SaveCallAsync_ProtectedDepartment_EncryptsAndResavesCascadeChildren() + { + var call = BuildCall(); + var attachment = new CallAttachment { CallAttachmentId = 7, FileName = "scene.jpg" }; + var note = new CallNote { CallNoteId = 8, Note = "note" }; + call.Attachments = new List { attachment }; + call.CallNotes = new List { note }; + + _protectedWriteService.Setup(x => x.PrepareCallWriteAsync(10, It.IsAny(), null, null, null, true, It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed(isProtected: true, changed: true)); + _protectedWriteService.Setup(x => x.PrepareCallAttachmentWriteAsync(10, attachment, null, null, true, It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed(isProtected: true, changed: true)); + _protectedWriteService.Setup(x => x.PrepareCallNoteWriteAsync(10, note, null, null, true, It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed(isProtected: true, changed: true)); + + await _service.SaveCallAsync(call); + + // Call re-saved once for the encrypted fields (2 total), each child re-saved once. + _callsRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + _callAttachmentRepo.Verify(x => x.SaveOrUpdateAsync(attachment, It.IsAny(), It.IsAny()), Times.Once); + _callNotesRepo.Verify(x => x.SaveOrUpdateAsync(note, It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task SaveCallAsync_ProtectedDepartment_UnchangedChildrenAreNotResaved() + { + var call = BuildCall(); + var attachment = new CallAttachment { CallAttachmentId = 7, FileName = "rgdp:1:1:AAAA" }; + call.Attachments = new List { attachment }; + + _protectedWriteService.Setup(x => x.PrepareCallWriteAsync(10, It.IsAny(), null, null, null, true, It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed(isProtected: true, changed: false)); + _protectedWriteService.Setup(x => x.PrepareCallAttachmentWriteAsync(10, attachment, null, null, true, It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed(isProtected: true, changed: false)); + + await _service.SaveCallAsync(call); + + _protectedWriteService.Verify(x => x.PrepareCallAttachmentWriteAsync(10, attachment, null, null, true, It.IsAny()), Times.Once); + _callsRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _callAttachmentRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task SaveCallAsync_RedactedSentinel_FetchesStoredRowAndPersistsTheRestore() + { + var call = BuildCall(); + call.Name = ProtectedDataEnvelope.RedactionValue; + var stored = new Call { CallId = 42, DepartmentId = 10, Name = "rgdp:1:1:storedname==" }; + _callsRepo.Setup(x => x.GetByIdAsync(42)).ReturnsAsync(stored); + + // The service passes the stored row through to Prepare; emulate its sentinel restore. + _protectedWriteService.Setup(x => x.PrepareCallWriteAsync(10, It.IsAny(), stored, null, null, true, It.IsAny())) + .Callback((d, c, e, g, u, w, ct) => c.Name = e.Name) + .ReturnsAsync(ProtectedWriteResult.Allowed(isProtected: true, changed: false)); + + var result = await _service.SaveCallAsync(call); + + result.Name.Should().Be("rgdp:1:1:storedname=="); + _protectedWriteService.Verify(x => x.PrepareCallWriteAsync(10, It.IsAny(), stored, null, null, true, It.IsAny()), Times.Once); + // Initial save + the restore re-persist (Changed=false but the placeholder row must be fixed). + _callsRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public async Task SaveCallAsync_NoSentinel_DoesNotFetchTheStoredRow() + { + var call = BuildCall(); + + await _service.SaveCallAsync(call); + + _callsRepo.Verify(x => x.GetByIdAsync(It.IsAny()), Times.Never); + _protectedWriteService.Verify(x => x.PrepareCallWriteAsync(10, It.IsAny(), null, null, null, true, It.IsAny()), Times.Once); + } + + [Test] + public async Task SaveCallAsync_BlockedCallWrite_Throws() + { + var call = BuildCall(); + + _protectedWriteService.Setup(x => x.PrepareCallWriteAsync(10, It.IsAny(), null, null, null, true, It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Blocked("broker_unavailable")); + + Func act = async () => await _service.SaveCallAsync(call); + + await act.Should().ThrowAsync().WithMessage("*broker_unavailable*"); + } + + [Test] + public async Task SaveCallAsync_BlockedCascadeAttachmentWrite_Throws() + { + var call = BuildCall(); + var attachment = new CallAttachment { CallAttachmentId = 7, FileName = "scene.jpg" }; + call.Attachments = new List { attachment }; + + _protectedWriteService.Setup(x => x.PrepareCallWriteAsync(10, It.IsAny(), null, null, null, true, It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed(isProtected: true, changed: false)); + _protectedWriteService.Setup(x => x.PrepareCallAttachmentWriteAsync(10, attachment, null, null, true, It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Blocked("broker_unavailable")); + + Func act = async () => await _service.SaveCallAsync(call); + + await act.Should().ThrowAsync().WithMessage("*call attachment 7*"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs b/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs index f002d256d..98122ae8f 100644 --- a/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs @@ -32,6 +32,8 @@ public class with_the_communication_test_service : TestBase protected Mock _departmentGroupsServiceMock; protected Mock _personnelRolesServiceMock; protected Mock _departmentSettingsServiceMock; + protected Mock _userStateServiceMock; + protected Mock _customStateServiceMock; protected Mock _smsServiceMock; protected Mock _emailServiceMock; protected Mock _pushServiceMock; @@ -84,6 +86,47 @@ protected void SetupRunAndResultPersistence() .ReturnsAsync((CommunicationTestResult r, CancellationToken c, bool f) => r); } + /// + /// Points the department at a staffing suppression setting and gives each named member a + /// current staffing level, the way the real services would for a department that has + /// configured "Supress (Mute) For These Staffing Levels". + /// + protected void SetupStaffing(int departmentId, bool enabled, int[] suppressedLevels, Dictionary levelsByUser) + { + _departmentSettingsServiceMock + .Setup(x => x.GetDepartmentStaffingSuppressInfoAsync(departmentId, It.IsAny())) + .ReturnsAsync(new DepartmentSuppressStaffingInfo + { + EnableSupressStaffing = enabled, + StaffingLevelsToSupress = (suppressedLevels ?? new int[0]).ToList() + }); + + var states = (levelsByUser ?? new Dictionary()) + .Select(kvp => new UserState + { + UserId = kvp.Key, + DepartmentId = departmentId, + State = kvp.Value, + Timestamp = DateTime.UtcNow.AddMinutes(-5) + }) + .ToList(); + + _userStateServiceMock + .Setup(x => x.GetLatestStatesForDepartmentAsync(departmentId, It.IsAny())) + .ReturnsAsync(states); + + _customStateServiceMock + .Setup(x => x.GetCustomPersonnelStaffingsOrDefaultsAsync(departmentId)) + .ReturnsAsync(new List + { + new CustomStateDetail { CustomStateDetailId = (int)UserStateTypes.Available, ButtonText = "Available" }, + new CustomStateDetail { CustomStateDetailId = (int)UserStateTypes.Delayed, ButtonText = "Delayed" }, + new CustomStateDetail { CustomStateDetailId = (int)UserStateTypes.Unavailable, ButtonText = "Off Duty" }, + new CustomStateDetail { CustomStateDetailId = (int)UserStateTypes.Committed, ButtonText = "Committed" }, + new CustomStateDetail { CustomStateDetailId = (int)UserStateTypes.OnShift, ButtonText = "On Shift" } + }); + } + /// /// Starts a run the way a caller does, then runs the worker-side build step, and returns the /// built run. Delivery is deliberately left out so audience tests stay off the send path. @@ -110,6 +153,8 @@ protected override void Before_all_tests() _departmentGroupsServiceMock = new Mock(); _personnelRolesServiceMock = new Mock(); _departmentSettingsServiceMock = new Mock(); + _userStateServiceMock = new Mock(); + _customStateServiceMock = new Mock(); _smsServiceMock = new Mock(); _emailServiceMock = new Mock(); _pushServiceMock = new Mock(); @@ -137,6 +182,8 @@ protected override void Before_all_tests() _departmentGroupsServiceMock.Object, _personnelRolesServiceMock.Object, _departmentSettingsServiceMock.Object, + _userStateServiceMock.Object, + _customStateServiceMock.Object, _smsServiceMock.Object, _emailServiceMock.Object, _pushServiceMock.Object, @@ -1368,5 +1415,242 @@ public async Task should_process_weekly_test_on_matching_day() Times.AtLeastOnce); } } + + /// + /// A communication test has to behave like a real dispatch: it must obey the member's own + /// channel settings and the department's Suppress (Mute) Staffing Levels setting. Otherwise it + /// reports a reachability figure no real call could hit -- and pages every off-duty member of + /// the department to produce it. + /// + [TestFixture] + public class when_building_a_run_against_user_and_department_settings : with_the_communication_test_service + { + private const int DepartmentId = 1; + + private Guid SetupTest(bool sms = true, bool email = false, bool voice = false, bool push = false) + { + var testId = Guid.NewGuid(); + + _communicationTestRepoMock.Setup(x => x.GetByIdAsync(testId)).ReturnsAsync(new CommunicationTest + { + CommunicationTestId = testId, + DepartmentId = DepartmentId, + TestSms = sms, + TestEmail = email, + TestVoice = voice, + TestPush = push, + ResponseWindowMinutes = 60, + Active = true + }); + + return testId; + } + + private void SetupMembers(params UserProfile[] profiles) + { + _departmentsServiceMock + .Setup(x => x.GetAllMembersForDepartmentAsync(DepartmentId)) + .ReturnsAsync(profiles.Select(p => new DepartmentMember { UserId = p.UserId, DepartmentId = DepartmentId }).ToList()); + + _userProfileServiceMock + .Setup(x => x.GetAllProfilesForDepartmentAsync(DepartmentId, false)) + .ReturnsAsync(profiles.ToDictionary(p => p.UserId, p => p)); + } + + private static UserProfile Reachable(string userId, bool sms = true, bool email = true, bool voice = true, bool push = true) + { + return new UserProfile + { + UserId = userId, + MembershipEmail = userId + "@test.com", + EmailVerified = true, + MobileNumber = "+15551234567", + MobileNumberVerified = true, + MobileCarrier = (int)MobileCarriers.Att, + SendSms = sms, + SendEmail = email, + VoiceForCall = voice, + VoiceCallMobile = true, + SendNotificationPush = push + }; + } + + private CommunicationTestResult ResultFor(string userId, CommunicationTestChannel channel) + { + return _savedResults.FirstOrDefault(r => r.UserId == userId && r.Channel == (int)channel); + } + + [Test] + public async Task should_not_attempt_a_send_to_a_member_on_a_suppressed_staffing_level() + { + var testId = SetupTest(sms: true, email: true, voice: true, push: true); + SetupMembers(Reachable(TestData.Users.TestUser1Id), Reachable(TestData.Users.TestUser2Id)); + + SetupStaffing(DepartmentId, true, new[] { (int)UserStateTypes.Unavailable }, new Dictionary + { + { TestData.Users.TestUser1Id, (int)UserStateTypes.Unavailable }, + { TestData.Users.TestUser2Id, (int)UserStateTypes.Available } + }); + + SetupRunAndResultPersistence(); + + await StartAndBuildAsync(testId, DepartmentId, TestData.Users.TestUser1Id); + + _savedResults.Where(r => r.UserId == TestData.Users.TestUser1Id) + .Should().OnlyContain(r => r.Suppressed && !r.SendAttempted); + + // The member who is not on a muted level is untouched by any of this. + _savedResults.Where(r => r.UserId == TestData.Users.TestUser2Id) + .Should().OnlyContain(r => !r.Suppressed && r.SendAttempted); + } + + [Test] + public async Task should_record_the_staffing_level_the_run_saw_so_the_report_can_explain_itself() + { + var testId = SetupTest(sms: true); + SetupMembers(Reachable(TestData.Users.TestUser1Id)); + + SetupStaffing(DepartmentId, true, new[] { (int)UserStateTypes.Unavailable }, new Dictionary + { + { TestData.Users.TestUser1Id, (int)UserStateTypes.Unavailable } + }); + + SetupRunAndResultPersistence(); + + await StartAndBuildAsync(testId, DepartmentId, TestData.Users.TestUser1Id); + + var result = ResultFor(TestData.Users.TestUser1Id, CommunicationTestChannel.Sms); + + result.Should().NotBeNull(); + result.StaffingLevel.Should().Be((int)UserStateTypes.Unavailable); + // The department's own name for the level, not the built-in one, and not the number. + result.StaffingLevelText.Should().Be("Off Duty"); + result.Suppressed.Should().BeTrue(); + } + + [Test] + public async Task should_leave_everyone_reachable_when_staffing_suppression_is_switched_off() + { + var testId = SetupTest(sms: true); + SetupMembers(Reachable(TestData.Users.TestUser1Id)); + + // The level IS on the muted list, but the feature itself is off, so it means nothing. + SetupStaffing(DepartmentId, false, new[] { (int)UserStateTypes.Unavailable }, new Dictionary + { + { TestData.Users.TestUser1Id, (int)UserStateTypes.Unavailable } + }); + + SetupRunAndResultPersistence(); + + await StartAndBuildAsync(testId, DepartmentId, TestData.Users.TestUser1Id); + + var result = ResultFor(TestData.Users.TestUser1Id, CommunicationTestChannel.Sms); + + result.Suppressed.Should().BeFalse(); + result.SendAttempted.Should().BeTrue(); + // Still recorded: the report shows the level whether or not it changed the outcome. + result.StaffingLevelText.Should().Be("Off Duty"); + } + + [Test] + public async Task should_not_suppress_a_member_who_has_never_set_a_staffing_level() + { + var testId = SetupTest(sms: true); + SetupMembers(Reachable(TestData.Users.TestUser1Id)); + + SetupStaffing(DepartmentId, true, new[] { (int)UserStateTypes.Available }, new Dictionary()); + + SetupRunAndResultPersistence(); + + await StartAndBuildAsync(testId, DepartmentId, TestData.Users.TestUser1Id); + + var result = ResultFor(TestData.Users.TestUser1Id, CommunicationTestChannel.Sms); + + result.Suppressed.Should().BeFalse(); + result.SendAttempted.Should().BeTrue(); + result.StaffingLevel.Should().BeNull(); + result.StaffingLevelText.Should().BeNull(); + } + + [Test] + public async Task should_record_each_members_own_election_per_channel() + { + var testId = SetupTest(sms: true, email: true, voice: true, push: true); + + // Everything on for user 1; user 2 has turned every channel off in their profile. + SetupMembers( + Reachable(TestData.Users.TestUser1Id), + Reachable(TestData.Users.TestUser2Id, sms: false, email: false, voice: false, push: false)); + + SetupStaffing(DepartmentId, false, new int[0], new Dictionary()); + SetupRunAndResultPersistence(); + + await StartAndBuildAsync(testId, DepartmentId, TestData.Users.TestUser1Id); + + _savedResults.Where(r => r.UserId == TestData.Users.TestUser1Id) + .Should().OnlyContain(r => r.ChannelEnabled == true && r.SendAttempted); + + // An election of "off" is not a send failure, so it is recorded as the election it is. + _savedResults.Where(r => r.UserId == TestData.Users.TestUser2Id) + .Should().OnlyContain(r => r.ChannelEnabled == false && !r.SendAttempted && !r.Suppressed); + } + + [Test] + public async Task should_record_an_election_of_on_even_when_the_contact_method_is_unverified() + { + var testId = SetupTest(sms: true); + + var profile = Reachable(TestData.Users.TestUser1Id); + profile.MobileNumberVerified = false; + SetupMembers(profile); + + SetupStaffing(DepartmentId, false, new int[0], new Dictionary()); + SetupRunAndResultPersistence(); + + await StartAndBuildAsync(testId, DepartmentId, TestData.Users.TestUser1Id); + + var result = ResultFor(TestData.Users.TestUser1Id, CommunicationTestChannel.Sms); + + // The member wants SMS; the number is what is blocking. The report has to be able to + // tell those two apart, so the election is recorded independently of the outcome. + result.ChannelEnabled.Should().BeTrue(); + result.SendAttempted.Should().BeFalse(); + } + + [Test] + public async Task should_send_nothing_to_a_suppressed_member_when_the_run_is_delivered() + { + var testId = SetupTest(sms: true, email: true, voice: true, push: true); + SetupMembers(Reachable(TestData.Users.TestUser1Id)); + + SetupStaffing(DepartmentId, true, new[] { (int)UserStateTypes.Unavailable }, new Dictionary + { + { TestData.Users.TestUser1Id, (int)UserStateTypes.Unavailable } + }); + + _departmentsServiceMock.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(new Department { DepartmentId = DepartmentId, Name = "Test Dept" }); + _departmentSettingsServiceMock.Setup(x => x.GetTextToCallNumberForDepartmentAsync(DepartmentId)) + .ReturnsAsync("15550001111"); + + SetupRunAndResultPersistence(); + + var run = await StartAndBuildAsync(testId, DepartmentId, TestData.Users.TestUser1Id); + await _communicationTestService.DeliverRunAsync(run.CommunicationTestRunId); + + _smsServiceMock.Verify( + x => x.SendCommunicationTestAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _emailServiceMock.Verify( + x => x.SendCommunicationTestEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + _outboundVoiceProviderMock.Verify( + x => x.SendCommunicationTestCallAsync(It.IsAny(), It.IsAny()), + Times.Never); + _pushServiceMock.Verify( + x => x.PushNotification(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + } } } diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs new file mode 100644 index 000000000..723b2ae48 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -0,0 +1,756 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Attended protected-read pipeline (plan section 7.1): unprotected passthrough, redaction with + /// machine-readable reasons for every grant failure mode, one batched broker round trip on a + /// valid grant, and fail-closed behavior on broker faults — a client never sees ciphertext. + /// + [TestFixture] + public class ProtectedReadServiceTests + { + private const int DeptId = 42; + private const long Epoch = 3; + private const string UserId = "user-1"; + + private X509Certificate2 _certificate; + private ProtectedDataGrantService _grantService; + private Mock _dataProtectionService; + private Mock _brokerClient; + private ProtectedReadService _service; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=adp-read-tests", ecdsa, HashAlgorithmName.SHA256); + _certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(2)); + } + + [OneTimeTearDown] + public void OneTimeTearDown() => _certificate?.Dispose(); + + [SetUp] + public void SetUp() + { + _grantService = new ProtectedDataGrantService(() => _certificate, () => _certificate); + + _dataProtectionService = new Mock(); + _dataProtectionService.Setup(x => x.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(true); + _dataProtectionService.Setup(x => x.GetPolicyByDepartmentIdAsync(DeptId, It.IsAny())) + .ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = DeptId, PolicyEpoch = Epoch, CatalogVersion = 1 }); + + _brokerClient = new Mock(); + + _service = new ProtectedReadService(_dataProtectionService.Object, _grantService, _brokerClient.Object); + } + + private string IssueGrant(string userId = UserId, long epoch = Epoch) + { + return _grantService.IssueGrant(new ProtectedDataGrantIssueRequest + { + UserId = userId, + DepartmentId = DeptId, + PolicyEpoch = epoch, + WindowMinutes = 15, + Scopes = new[] { ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write }, + MfaAtUtc = DateTime.UtcNow + }).Token; + } + + private static Call EnvelopedCall(int callId = 17) => new Call + { + CallId = callId, + DepartmentId = DeptId, + Number = "C-100", + Name = "rgdp:1:1:name==", + NatureOfCall = "rgdp:1:1:nature==", + Address = "rgdp:1:1:address==", + Notes = null + }; + + [Test] + public async Task Unprotected_department_passes_through_untouched() + { + _dataProtectionService.Setup(x => x.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(false); + var call = new Call { CallId = 1, Name = "Structure Fire", NatureOfCall = "Smoke showing" }; + + var result = await _service.ResolveForReadAsync(DeptId, call, null, UserId); + + result.IsProtected.Should().BeFalse(); + result.ProtectedReason.Should().BeNull(); + result.RedactedFields.Should().BeEmpty(); + result.Call.Name.Should().Be("Structure Fire"); + _brokerClient.Verify(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Missing_grant_redacts_every_enveloped_field_with_step_up_required() + { + var call = EnvelopedCall(); + + var result = await _service.ResolveForReadAsync(DeptId, call, null, UserId); + + result.IsProtected.Should().BeTrue(); + result.ProtectedReason.Should().Be("step_up_required"); + result.RedactedFields.Should().BeEquivalentTo("calls.name", "calls.natureofcall", "calls.address"); + result.Call.Name.Should().Be(ProtectedDataEnvelope.RedactionValue); + result.Call.NatureOfCall.Should().Be(ProtectedDataEnvelope.RedactionValue); + result.Call.Address.Should().Be(ProtectedDataEnvelope.RedactionValue); + result.Call.Number.Should().Be("C-100", "non-cataloged fields are untouched"); + _brokerClient.Verify(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Revoked_grant_after_epoch_bump_redacts_with_grant_revoked() + { + var staleGrant = IssueGrant(epoch: Epoch - 1); + + var result = await _service.ResolveForReadAsync(DeptId, EnvelopedCall(), staleGrant, UserId); + + result.ProtectedReason.Should().Be("grant_revoked"); + result.Call.Name.Should().Be(ProtectedDataEnvelope.RedactionValue); + } + + [Test] + public async Task Grant_bound_to_another_user_redacts_with_protected_access_denied() + { + var foreignGrant = IssueGrant(userId: "someone-else"); + + var result = await _service.ResolveForReadAsync(DeptId, EnvelopedCall(), foreignGrant, UserId); + + result.ProtectedReason.Should().Be("protected_access_denied"); + result.Call.Name.Should().Be(ProtectedDataEnvelope.RedactionValue); + } + + [Test] + public async Task Valid_grant_batches_one_broker_request_and_substitutes_plaintext() + { + var calls = new List { EnvelopedCall(17), EnvelopedCall(18) }; + IReadOnlyList sentItems = null; + _brokerClient.Setup(x => x.DecryptAsync(DeptId, It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>( + (d, g, r, items, ct) => sentItems = items) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult + { + FieldId = i.FieldId, + RowKey = i.RowKey, + Value = $"plain:{i.RowKey}:{i.FieldId}" + }).ToList() + }); + + var results = await _service.ResolveForReadAsync(DeptId, calls, IssueGrant(), UserId); + + sentItems.Should().HaveCount(6, "three enveloped fields per call, one batch"); + results.Should().OnlyContain(r => r.ProtectedReason == null && r.RedactedFields.Count == 0 && r.IsProtected); + results[0].Call.Name.Should().Be("plain:17:calls.name"); + results[1].Call.NatureOfCall.Should().Be("plain:18:calls.natureofcall"); + _brokerClient.Verify(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Broker_fault_redacts_everything_and_never_leaks_ciphertext() + { + _brokerClient.Setup(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync(new ProtectedDataBrokerResult { Success = false, ErrorCode = "broker_unavailable" }); + + var result = await _service.ResolveForReadAsync(DeptId, EnvelopedCall(), IssueGrant(), UserId); + + result.ProtectedReason.Should().Be("broker_unavailable"); + result.Call.Name.Should().Be(ProtectedDataEnvelope.RedactionValue); + result.Call.NatureOfCall.Should().NotStartWith("rgdp:"); + } + + [Test] + public async Task Per_item_broker_error_redacts_only_that_field() + { + _brokerClient.Setup(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => i.FieldId == "calls.address" + ? new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, ErrorCode = "decrypt_failed" } + : new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = "plain" }).ToList() + }); + + var result = await _service.ResolveForReadAsync(DeptId, EnvelopedCall(), IssueGrant(), UserId); + + result.Call.Name.Should().Be("plain"); + result.Call.Address.Should().Be(ProtectedDataEnvelope.RedactionValue); + result.RedactedFields.Should().BeEquivalentTo("calls.address"); + result.ProtectedReason.Should().Be("broker_unavailable"); + } + + [Test] + public void Field_accessor_map_covers_every_catalog_v1_calls_column() + { + var callsBinding = AdpTableBindings.V1.Single(b => b.TableName == "Calls"); + var boundFieldIds = callsBinding.Columns.Select(c => c.FieldId).ToList(); + + ProtectedReadService.CallFieldAccessors.Keys.Should().BeEquivalentTo(boundFieldIds, + "a catalog binding without a read accessor would silently leak an envelope"); + } + + [Test] + public void Child_accessor_maps_cover_every_catalog_v1_child_column() + { + var notesBinding = AdpTableBindings.V1.Single(b => b.TableName == "CallNotes"); + var noteAccessorIds = ProtectedReadService.NoteFieldAccessors.Keys + .Concat(ProtectedReadService.NoteCompanionAccessors.Keys); + noteAccessorIds.Should().BeEquivalentTo(notesBinding.Columns.Select(c => c.FieldId)); + + var attachmentsBinding = AdpTableBindings.V1.Single(b => b.TableName == "CallAttachments"); + var attachmentAccessorIds = ProtectedReadService.AttachmentFieldAccessors.Keys + .Concat(ProtectedReadService.AttachmentCompanionAccessors.Keys) + .Concat(new[] { ProtectedReadService.AttachmentDataFieldId }); + attachmentAccessorIds.Should().BeEquivalentTo(attachmentsBinding.Columns.Select(c => c.FieldId)); + + var contactsBinding = AdpTableBindings.V1.Single(b => b.TableName == "Contacts"); + var contactAccessorIds = ProtectedReadService.ContactFieldAccessors.Keys + .Concat(new[] { ProtectedReadService.ContactImageFieldId }); + contactAccessorIds.Should().BeEquivalentTo(contactsBinding.Columns.Select(c => c.FieldId)); + + var contactNotesBinding = AdpTableBindings.V1.Single(b => b.TableName == "ContactNotes"); + ProtectedReadService.ContactNoteFieldAccessors.Keys + .Should().BeEquivalentTo(contactNotesBinding.Columns.Select(c => c.FieldId)); + } + + [Test] + public async Task Contacts_redact_without_a_grant_and_strip_the_enveloped_image() + { + var contact = new Contact + { + ContactId = "c-1", + DepartmentId = DeptId, + FirstName = "rgdp:1:1:first==", + CellPhoneNumber = "rgdp:1:1:cell==", + Website = "https://example.org", + Image = System.Text.Encoding.ASCII.GetBytes("rgdpb:1:1:").Concat(new byte[] { 1, 2 }).ToArray() + }; + + var result = await _service.ResolveContactsForReadAsync(DeptId, new[] { contact }, null, UserId); + + result.IsProtected.Should().BeTrue(); + result.ProtectedReason.Should().Be("step_up_required"); + result.RedactedFields.Should().BeEquivalentTo("contacts.firstname", "contacts.cellphonenumber"); + contact.FirstName.Should().Be(ProtectedDataEnvelope.RedactionValue); + contact.CellPhoneNumber.Should().Be(ProtectedDataEnvelope.RedactionValue); + contact.Website.Should().Be("https://example.org", "non-cataloged fields are untouched"); + contact.Image.Should().BeNull("enveloped image bytes must never ride out through a serializer"); + } + + [Test] + public async Task Contacts_reveal_with_a_valid_grant_in_one_broker_batch() + { + var contact = new Contact { ContactId = "c-1", DepartmentId = DeptId, FirstName = "rgdp:1:1:first==", LastName = "rgdp:1:1:last==" }; + _brokerClient.Setup(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult + { + FieldId = i.FieldId, + RowKey = i.RowKey, + Value = i.FieldId == "contacts.firstname" ? "Jane" : "Smith" + }).ToList() + }); + + var result = await _service.ResolveContactsForReadAsync(DeptId, new[] { contact }, IssueGrant(), UserId); + + result.ProtectedReason.Should().BeNull(); + contact.FirstName.Should().Be("Jane"); + contact.LastName.Should().Be("Smith"); + _brokerClient.Verify(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Once); + } + + // ── protected writes ───────────────────────────────────────────────────── + + private void SetupWriteEnforced(bool enforced = true) + { + _dataProtectionService.Setup(x => x.ShouldEncryptNewWritesAsync(DeptId)).ReturnsAsync(enforced); + } + + private void SetupEncryptEcho() + { + _brokerClient.Setup(x => x.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult + { + FieldId = i.FieldId, + RowKey = i.RowKey, + Value = i.IsBinary + ? Convert.ToBase64String(System.Text.Encoding.ASCII.GetBytes("rgdpb:1:1:").Concat(new byte[] { 5 }).ToArray()) + : $"rgdp:1:1:{i.FieldId}==" + }).ToList() + }); + } + + [Test] + public async Task Write_passes_through_when_the_department_is_not_in_an_encrypt_state() + { + SetupWriteEnforced(false); + var call = new Call { CallId = 17, DepartmentId = DeptId, Name = "Structure Fire" }; + + var result = await _service.PrepareCallWriteAsync(DeptId, call, null, null, UserId, workloadCaller: false); + + result.Success.Should().BeTrue(); + result.IsProtected.Should().BeFalse(); + call.Name.Should().Be("Structure Fire"); + } + + [Test] + public async Task Attended_write_without_a_grant_is_blocked_before_any_broker_call() + { + SetupWriteEnforced(); + var call = new Call { CallId = 17, DepartmentId = DeptId, Name = "Structure Fire" }; + + var result = await _service.PrepareCallWriteAsync(DeptId, call, null, null, UserId, workloadCaller: false); + + result.Success.Should().BeFalse(); + result.Reason.Should().Be("step_up_required"); + call.Name.Should().Be("Structure Fire", "a blocked write must not half-mutate the entity"); + _brokerClient.Verify(x => x.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Attended_write_with_a_valid_grant_envelopes_every_cataloged_field_in_one_batch() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + var call = new Call { CallId = 17, DepartmentId = DeptId, Number = "C-100", Name = "Structure Fire", NatureOfCall = "Smoke showing" }; + + var result = await _service.PrepareCallWriteAsync(DeptId, call, null, IssueGrant(), UserId, workloadCaller: false); + + result.Success.Should().BeTrue(); + result.IsProtected.Should().BeTrue(); + call.Name.Should().Be("rgdp:1:1:calls.name=="); + call.NatureOfCall.Should().Be("rgdp:1:1:calls.natureofcall=="); + call.Number.Should().Be("C-100", "non-cataloged fields are untouched"); + _brokerClient.Verify(x => x.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Workload_write_encrypts_without_a_grant_through_the_workload_lane() + { + SetupWriteEnforced(); + string sentGrant = "unset"; + _brokerClient.Setup(x => x.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>( + (d, g, r, items, ct) => sentGrant = g) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = "rgdp:1:1:x==" }).ToList() + }); + var call = new Call { CallId = 17, DepartmentId = DeptId, Name = "Text-to-call import" }; + + var result = await _service.PrepareCallWriteAsync(DeptId, call, null, null, UserId, workloadCaller: true); + + result.Success.Should().BeTrue(); + sentGrant.Should().BeNull("the workload lane sends no grant"); + call.Name.Should().StartWith("rgdp:"); + } + + [Test] + public async Task Redacted_sentinel_on_an_edit_restores_the_stored_envelope() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + var stored = new Call { CallId = 17, Name = "rgdp:1:1:storedname==", NatureOfCall = "rgdp:1:1:storednature==" }; + var edited = new Call + { + CallId = 17, + DepartmentId = DeptId, + Name = ProtectedDataEnvelope.RedactionValue, + NatureOfCall = "Updated nature" + }; + + var result = await _service.PrepareCallWriteAsync(DeptId, edited, stored, IssueGrant(), UserId, workloadCaller: false); + + result.Success.Should().BeTrue(); + edited.Name.Should().Be("rgdp:1:1:storedname==", "REDACTED means unchanged — the stored envelope survives"); + edited.NatureOfCall.Should().Be("rgdp:1:1:calls.natureofcall==", "genuinely changed fields encrypt"); + } + + [Test] + public async Task Redacted_sentinel_without_a_stored_row_is_never_encrypted() + { + SetupWriteEnforced(); + IReadOnlyList sentItems = null; + _brokerClient.Setup(x => x.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>( + (d, g, r, items, ct) => sentItems = items) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = $"rgdp:1:1:{i.FieldId}==" }).ToList() + }); + var call = new Call + { + CallId = 17, + DepartmentId = DeptId, + Name = ProtectedDataEnvelope.RedactionValue, + NatureOfCall = "Smoke showing" + }; + + var result = await _service.PrepareCallWriteAsync(DeptId, call, null, null, UserId, workloadCaller: true); + + result.Success.Should().BeTrue(); + sentItems.Select(i => i.FieldId).Should().BeEquivalentTo(new[] { "calls.natureofcall" }, + "the placeholder must never be enveloped — that would destroy the original"); + call.Name.Should().Be(ProtectedDataEnvelope.RedactionValue); + } + + [Test] + public async Task Broker_fault_blocks_the_write_and_applies_nothing() + { + SetupWriteEnforced(); + _brokerClient.Setup(x => x.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync(new ProtectedDataBrokerResult { Success = false, ErrorCode = "broker_unavailable" }); + var call = new Call { CallId = 17, DepartmentId = DeptId, Name = "Structure Fire" }; + + var result = await _service.PrepareCallWriteAsync(DeptId, call, null, IssueGrant(), UserId, workloadCaller: false); + + result.Success.Should().BeFalse(); + result.Reason.Should().Be("broker_unavailable"); + call.Name.Should().Be("Structure Fire", "all-or-nothing: nothing applies on failure"); + } + + [Test] + public async Task Note_write_moves_coordinates_into_companion_envelopes_and_marks_the_row() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + var note = new CallNote { CallNoteId = 7, CallId = 17, Note = "Occupant on O2", Latitude = 39.19m, Longitude = -119.76m }; + + var result = await _service.PrepareCallNoteWriteAsync(DeptId, note, IssueGrant(), UserId, workloadCaller: false); + + result.Success.Should().BeTrue(); + note.IsProtected.Should().BeTrue(); + note.Note.Should().Be("rgdp:1:1:callnotes.note=="); + note.Latitude.Should().BeNull("the typed column nulls; the envelope companion carries the value"); + note.ProtectedLatitudeEnvelope.Should().Be("rgdp:1:1:callnotes.latitude=="); + note.Longitude.Should().BeNull(); + note.ProtectedLongitudeEnvelope.Should().Be("rgdp:1:1:callnotes.longitude=="); + } + + [Test] + public async Task Attachment_write_encrypts_the_binary_payload_too() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + var attachment = new CallAttachment + { + CallAttachmentId = 9, + CallId = 17, + FileName = "photo.png", + Data = new byte[] { 1, 2, 3 } + }; + + var result = await _service.PrepareCallAttachmentWriteAsync(DeptId, attachment, IssueGrant(), UserId, workloadCaller: false); + + result.Success.Should().BeTrue(); + attachment.IsProtected.Should().BeTrue(); + attachment.FileName.Should().StartWith("rgdp:"); + System.Text.Encoding.ASCII.GetString(attachment.Data, 0, 6).Should().Be("rgdpb:"); + } + + [Test] + public async Task Write_preflight_blocks_attended_callers_without_a_grant_and_passes_workload_callers() + { + SetupWriteEnforced(); + + var attended = await _service.PreflightWriteAsync(DeptId, null, UserId, workloadCaller: false); + attended.Success.Should().BeFalse(); + attended.Reason.Should().Be("step_up_required"); + + var workload = await _service.PreflightWriteAsync(DeptId, null, UserId, workloadCaller: true); + workload.Success.Should().BeTrue(); + workload.IsProtected.Should().BeTrue(); + } + + [Test] + public async Task Contact_notes_redact_without_a_grant() + { + var note = new ContactNote { ContactNoteId = "cn-1", ContactId = "c-1", Note = "rgdp:1:1:note==" }; + + var result = await _service.ResolveContactNotesForReadAsync(DeptId, new[] { note }, null, UserId); + + result.ProtectedReason.Should().Be("step_up_required"); + result.RedactedFields.Should().BeEquivalentTo("contactnotes.note"); + note.Note.Should().Be(ProtectedDataEnvelope.RedactionValue); + } + + private static CallNote EnvelopedNote(int noteId = 7) => new CallNote + { + CallNoteId = noteId, + CallId = 17, + Note = "rgdp:1:1:note==", + IsProtected = true, + ProtectedLatitudeEnvelope = "rgdp:1:1:lat==", + ProtectedLongitudeEnvelope = "rgdp:1:1:lon==" + }; + + [Test] + public async Task Notes_redact_text_and_leave_companion_coordinates_null_without_a_grant() + { + var note = EnvelopedNote(); + + var result = await _service.ResolveNotesForReadAsync(DeptId, new[] { note }, null, UserId); + + result.IsProtected.Should().BeTrue(); + result.ProtectedReason.Should().Be("step_up_required"); + result.RedactedFields.Should().BeEquivalentTo("callnotes.note", "callnotes.latitude", "callnotes.longitude"); + note.Note.Should().Be(ProtectedDataEnvelope.RedactionValue); + note.Latitude.Should().BeNull(); + note.Longitude.Should().BeNull(); + } + + [Test] + public async Task Notes_reveal_text_and_parse_companion_coordinates_with_a_valid_grant() + { + var note = EnvelopedNote(); + _brokerClient.Setup(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult + { + FieldId = i.FieldId, + RowKey = i.RowKey, + Value = i.FieldId switch + { + "callnotes.note" => "Occupant on O2", + "callnotes.latitude" => "39.1911420", + _ => "-119.7674100" + } + }).ToList() + }); + + var result = await _service.ResolveNotesForReadAsync(DeptId, new[] { note }, IssueGrant(), UserId); + + result.ProtectedReason.Should().BeNull(); + note.Note.Should().Be("Occupant on O2"); + note.Latitude.Should().Be(39.1911420m); + note.Longitude.Should().Be(-119.7674100m); + } + + private static byte[] BinaryEnvelope() => + System.Text.Encoding.ASCII.GetBytes("rgdpb:1:1:").Concat(new byte[] { 1, 2, 3, 4 }).ToArray(); + + [Test] + public async Task Attachment_data_is_stripped_on_metadata_only_reads_and_decrypted_when_opted_in() + { + var metadataOnly = new CallAttachment { CallAttachmentId = 9, CallId = 17, FileName = "rgdp:1:1:fn==", Data = BinaryEnvelope(), IsProtected = true }; + await _service.ResolveAttachmentsForReadAsync(DeptId, new[] { metadataOnly }, null, UserId, includeData: false); + metadataOnly.Data.Should().BeNull("ciphertext bytes must never ride out on a metadata read"); + metadataOnly.FileName.Should().Be(ProtectedDataEnvelope.RedactionValue); + + var plaintextBytes = new byte[] { 9, 9, 9 }; + _brokerClient.Setup(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult + { + FieldId = i.FieldId, + RowKey = i.RowKey, + Value = i.FieldId == ProtectedReadService.AttachmentDataFieldId + ? Convert.ToBase64String(plaintextBytes) + : "photo.png" + }).ToList() + }); + + var withData = new CallAttachment { CallAttachmentId = 10, CallId = 17, FileName = "rgdp:1:1:fn==", Data = BinaryEnvelope(), IsProtected = true }; + var result = await _service.ResolveAttachmentsForReadAsync(DeptId, new[] { withData }, IssueGrant(), UserId, includeData: true); + + result.ProtectedReason.Should().BeNull(); + withData.FileName.Should().Be("photo.png"); + withData.Data.Should().BeEquivalentTo(plaintextBytes); + } + + [Test] + public async Task Attachment_data_redacts_to_null_when_the_grant_is_missing_on_a_data_read() + { + var attachment = new CallAttachment { CallAttachmentId = 11, CallId = 17, FileName = "photo.png", Data = BinaryEnvelope(), IsProtected = true }; + + var result = await _service.ResolveAttachmentsForReadAsync(DeptId, new[] { attachment }, null, UserId, includeData: true); + + result.ProtectedReason.Should().Be("step_up_required"); + result.RedactedFields.Should().Contain(ProtectedReadService.AttachmentDataFieldId); + attachment.Data.Should().BeNull(); + } + + [Test] + public async Task Call_resolution_carries_populated_children_in_the_same_batch() + { + var call = EnvelopedCall(); + call.CallNotes = new List { EnvelopedNote() }; + IReadOnlyList sentItems = null; + _brokerClient.Setup(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>( + (d, g, r, items, ct) => sentItems = items) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = "1" }).ToList() + }); + + await _service.ResolveForReadAsync(DeptId, call, IssueGrant(), UserId); + + sentItems.Should().NotBeNull(); + sentItems.Select(i => i.FieldId).Should().Contain(new[] { "calls.name", "callnotes.note", "callnotes.latitude" }); + _brokerClient.Verify(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Once); + } + + // ── contact writes ─────────────────────────────────────────────────────── + + [Test] + public async Task Contact_write_envelopes_text_fields_and_the_binary_image() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + var contact = new Contact + { + ContactId = "contact-guid-1", + DepartmentId = DeptId, + FirstName = "Pat", + LastName = "Doe", + CellPhoneNumber = "555-0100", + Image = new byte[] { 1, 2, 3 } + }; + + var result = await _service.PrepareContactWriteAsync(DeptId, contact, null, IssueGrant(), UserId, workloadCaller: false); + + result.Success.Should().BeTrue(); + result.Changed.Should().BeTrue(); + contact.FirstName.Should().Be("rgdp:1:1:contacts.firstname=="); + contact.LastName.Should().Be("rgdp:1:1:contacts.lastname=="); + contact.CellPhoneNumber.Should().Be("rgdp:1:1:contacts.cellphonenumber=="); + ProtectedReadService.IsBinaryEnveloped(contact.Image).Should().BeTrue(); + } + + [Test] + public async Task Contact_write_skips_enveloped_values_and_the_workload_lane_sends_no_grant() + { + SetupWriteEnforced(); + string sentGrant = "unset"; + IReadOnlyList sentItems = null; + _brokerClient.Setup(x => x.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>( + (d, g, r, items, ct) => { sentGrant = g; sentItems = items; }) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = $"rgdp:1:1:{i.FieldId}==" }).ToList() + }); + var contact = new Contact + { + ContactId = "contact-guid-1", + DepartmentId = DeptId, + FirstName = "rgdp:1:1:already==", + LastName = "Doe" + }; + + var result = await _service.PrepareContactWriteAsync(DeptId, contact, null, null, UserId, workloadCaller: true); + + result.Success.Should().BeTrue(); + sentGrant.Should().BeNull("the workload lane sends no grant"); + sentItems.Select(i => i.FieldId).Should().BeEquivalentTo(new[] { "contacts.lastname" }); + contact.FirstName.Should().Be("rgdp:1:1:already==", "already-enveloped values are never re-encrypted"); + } + + [Test] + public async Task Contact_redacted_sentinel_on_an_edit_restores_the_stored_envelope() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + var stored = new Contact { ContactId = "contact-guid-1", FirstName = "rgdp:1:1:storedfirst==" }; + var edited = new Contact + { + ContactId = "contact-guid-1", + DepartmentId = DeptId, + FirstName = ProtectedDataEnvelope.RedactionValue, + LastName = "Updated" + }; + + var result = await _service.PrepareContactWriteAsync(DeptId, edited, stored, IssueGrant(), UserId, workloadCaller: false); + + result.Success.Should().BeTrue(); + edited.FirstName.Should().Be("rgdp:1:1:storedfirst=="); + edited.LastName.Should().Be("rgdp:1:1:contacts.lastname=="); + } + + [Test] + public async Task Contact_note_write_envelopes_the_note_and_a_broker_fault_applies_nothing() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + var note = new ContactNote { ContactNoteId = "note-guid-1", ContactId = "contact-guid-1", DepartmentId = DeptId, Note = "Gate code 4411" }; + + var result = await _service.PrepareContactNoteWriteAsync(DeptId, note, IssueGrant(), UserId, workloadCaller: false); + + result.Success.Should().BeTrue(); + note.Note.Should().Be("rgdp:1:1:contactnotes.note=="); + + _brokerClient.Setup(x => x.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync(new ProtectedDataBrokerResult { Success = false, ErrorCode = "broker_unavailable" }); + var faulted = new ContactNote { ContactNoteId = "note-guid-2", ContactId = "contact-guid-1", DepartmentId = DeptId, Note = "Second note" }; + + var blocked = await _service.PrepareContactNoteWriteAsync(DeptId, faulted, IssueGrant(), UserId, workloadCaller: false); + + blocked.Success.Should().BeFalse(); + blocked.Reason.Should().Be("broker_unavailable"); + faulted.Note.Should().Be("Second note", "all-or-nothing: nothing applies on failure"); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/CallFilesSignedLinkTests.cs b/Tests/Resgrid.Tests/Web/Services/CallFilesSignedLinkTests.cs new file mode 100644 index 000000000..105dd428f --- /dev/null +++ b/Tests/Resgrid.Tests/Web/Services/CallFilesSignedLinkTests.cs @@ -0,0 +1,65 @@ +using System; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Web.Services.Controllers.v4; + +namespace Resgrid.Tests.Web.Services +{ + /// + /// Signed anonymous file-link validation (CallFiles/GetFile): expiring three-part links are + /// enforced, legacy two-part links survive only while the transition flag allows them, and + /// malformed payloads always read as invalid. + /// + [TestFixture] + public class CallFilesSignedLinkTests + { + private bool _originalAllowLegacy; + + [SetUp] + public void SetUp() => _originalAllowLegacy = SecurityConfig.AllowLegacySignedFileLinks; + + [TearDown] + public void TearDown() => SecurityConfig.AllowLegacySignedFileLinks = _originalAllowLegacy; + + [Test] + public void Unexpired_three_part_link_is_valid() + { + var payload = $"42|17|{DateTime.UtcNow.AddHours(1).Ticks}"; + + CallFilesController.TryValidateSignedFileQuery(payload, out var dept, out var attachment).Should().BeTrue(); + dept.Should().Be(42); + attachment.Should().Be(17); + } + + [Test] + public void Expired_link_is_refused() + { + var payload = $"42|17|{DateTime.UtcNow.AddMinutes(-1).Ticks}"; + + CallFilesController.TryValidateSignedFileQuery(payload, out _, out _).Should().BeFalse(); + } + + [Test] + public void Legacy_two_part_link_honors_the_transition_flag() + { + SecurityConfig.AllowLegacySignedFileLinks = true; + CallFilesController.TryValidateSignedFileQuery("42|17", out _, out _).Should().BeTrue(); + + SecurityConfig.AllowLegacySignedFileLinks = false; + CallFilesController.TryValidateSignedFileQuery("42|17", out _, out _).Should().BeFalse(); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("42")] + [TestCase("0|17")] + [TestCase("abc|17")] + [TestCase("42|abc")] + [TestCase("42|17|not-ticks")] + public void Malformed_payloads_are_refused(string payload) + { + CallFilesController.TryValidateSignedFileQuery(payload, out _, out _).Should().BeFalse(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs index 81941567c..985040ba4 100644 --- a/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs @@ -32,6 +32,7 @@ public class CallsControllerTests private Mock _authorizationService; private Mock _protocolsService; private Mock _dataProtectionService; + private Mock _protectedCallReadService; private CallsController _controller; private Activity _activity; @@ -43,6 +44,21 @@ public void SetUp() _protocolsService = new Mock(); _dataProtectionService = new Mock(); + // Pass-through protected reads: these tests exercise unprotected departments, where the + // resolver returns the calls untouched. + _protectedCallReadService = new Mock(); + _protectedCallReadService + .Setup(x => x.ResolveForReadAsync(It.IsAny(), It.IsAny>(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns, string, string, CancellationToken>((d, calls, g, u, ct) => + Task.FromResult>( + (calls ?? new List()).Select(c => new ProtectedReadResult { Call = c }).ToList())); + _protectedCallReadService + .Setup(x => x.ResolveForReadAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns((d, call, g, u, ct) => + Task.FromResult(new ProtectedReadResult { Call = call })); + var httpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] @@ -78,7 +94,9 @@ public void SetUp() Mock.Of(), Mock.Of(), Mock.Of(), - _dataProtectionService.Object) + _dataProtectionService.Object, + _protectedCallReadService.Object, + Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = httpContext } }; diff --git a/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs b/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs index f08bf8fe3..a1fd0cc67 100644 --- a/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs +++ b/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs @@ -76,11 +76,21 @@ private async Task ProcessAsync(BrokerFieldOperationR var policy = await policyRepository.GetByDepartmentIdAsync(request.DepartmentId); var currentEpoch = policy?.PolicyEpoch ?? 0; - var requiredScope = decrypt ? ProtectedDataGrantScopes.Read : ProtectedDataGrantScopes.Write; - var outcome = _grantService.ValidateGrant(request.GrantToken, request.DepartmentId, currentEpoch, - requiredScope, out var grant); - if (outcome != ProtectedDataGrantValidationOutcome.Valid) - return Fail(MapGrantOutcome(outcome)); + // DECRYPT always requires a valid attended grant. ENCRYPT has a workload lane (plan 3.4 + // "required protected submissions"): a request WITHOUT a grant — already past the + // workload-key middleware — may encrypt, because encryption discloses nothing; system + // integrations, text-to-call and workers must never be blocked from writing safely. A + // grant that IS presented is still fully validated, so a stolen/stale token cannot be + // laundered through the encrypt path either. + ProtectedDataGrant grant = null; + if (decrypt || !string.IsNullOrWhiteSpace(request.GrantToken)) + { + var requiredScope = decrypt ? ProtectedDataGrantScopes.Read : ProtectedDataGrantScopes.Write; + var outcome = _grantService.ValidateGrant(request.GrantToken, request.DepartmentId, currentEpoch, + requiredScope, out grant); + if (outcome != ProtectedDataGrantValidationOutcome.Valid) + return Fail(MapGrantOutcome(outcome)); + } var result = new ProtectedDataBrokerResult { Success = true }; var unwrappedKeys = new Dictionary(); @@ -123,6 +133,49 @@ private async Task DecryptItemsAsync(BrokerFieldOperationRequest request, IDepar continue; } + if (item.IsBinary) + { + // rgdpb binary field: Value is base64 of the enveloped blob; the plaintext bytes + // go back as base64 too. + byte[] blob; + try + { + blob = Convert.FromBase64String(item.Value ?? string.Empty); + } + catch (FormatException) + { + itemResult.ErrorCode = "invalid_item"; + continue; + } + + if (!_cryptoService.TryGetBinaryEnvelopeKeyVersion(blob, out var binaryKeyVersion)) + { + itemResult.ErrorCode = _cryptoService.IsBinaryEnveloped(blob) + ? "envelope_malformed" + : "not_enveloped"; + continue; + } + + var binaryDek = await ResolveKeyAsync(request.DepartmentId, binaryKeyVersion, keyService, unwrappedKeys, cancellationToken); + if (binaryDek == null) + { + itemResult.ErrorCode = "key_unknown"; + continue; + } + + try + { + itemResult.Value = Convert.ToBase64String(_cryptoService.DecryptBinary(binaryDek, blob, + request.DepartmentId, item.FieldId, item.RowKey, item.CatalogVersion)); + } + catch (Exception ex) when (ex is CryptographicException || ex is FormatException || ex is ArgumentException) + { + itemResult.ErrorCode = "decrypt_failed"; + } + + continue; + } + if (!ProtectedDataEnvelope.TryParse(item.Value, out var formatVersion, out var keyVersion, out _) || formatVersion > ProtectedDataEnvelope.CurrentVersion) { @@ -180,6 +233,45 @@ private async Task EncryptItemsAsync(BrokerFieldOperationRequest request, IDepar continue; } + if (item.IsBinary) + { + byte[] plaintextBytes; + try + { + plaintextBytes = Convert.FromBase64String(item.Value); + } + catch (FormatException) + { + itemResult.ErrorCode = "invalid_item"; + continue; + } + + if (_cryptoService.IsBinaryEnveloped(plaintextBytes)) + { + itemResult.ErrorCode = "already_enveloped"; + continue; + } + + var binaryDek = await ResolveKeyAsync(request.DepartmentId, activeKey.Version, keyService, unwrappedKeys, cancellationToken); + if (binaryDek == null) + { + itemResult.ErrorCode = "key_unknown"; + continue; + } + + try + { + itemResult.Value = Convert.ToBase64String(_cryptoService.EncryptBinary(binaryDek, activeKey.Version, + plaintextBytes, request.DepartmentId, item.FieldId, item.RowKey, item.CatalogVersion)); + } + catch (Exception ex) when (ex is CryptographicException || ex is ArgumentException || ex is InvalidOperationException) + { + itemResult.ErrorCode = "encrypt_failed"; + } + + continue; + } + if (ProtectedDataEnvelope.HasEnvelopePrefix(item.Value)) { // Double-encryption guard: enveloped input reaching an encrypt call is a caller @@ -260,7 +352,8 @@ private static void Audit(string operation, BrokerFieldOperationRequest request, { var failed = result.Items.Count(i => i.ErrorCode != null); var fields = string.Join(",", request.Items.Where(i => i?.FieldId != null).Select(i => i.FieldId).Distinct()); - Logging.LogInfo($"ADP broker {operation}: department {request.DepartmentId}, user {grant.UserId}, grant {grant.GrantId}, request {request.RequestId}, items {result.Items.Count}, failed {failed}, fields [{fields}]"); + var identity = grant == null ? "workload" : $"user {grant.UserId}, grant {grant.GrantId}"; + Logging.LogInfo($"ADP broker {operation}: department {request.DepartmentId}, {identity}, request {request.RequestId}, items {result.Items.Count}, failed {failed}, fields [{fields}]"); } } } diff --git a/Web/Resgrid.Web.Broker/Startup.cs b/Web/Resgrid.Web.Broker/Startup.cs index ab8a0f4f1..ba6b51cb7 100644 --- a/Web/Resgrid.Web.Broker/Startup.cs +++ b/Web/Resgrid.Web.Broker/Startup.cs @@ -96,6 +96,10 @@ public void ConfigureContainer(ContainerBuilder builder) builder.RegisterModule(new Resgrid.Providers.Weather.WeatherProviderModule()); builder.RegisterModule(new Resgrid.Providers.Workflow.WorkflowProviderModule()); + // The broker also registers the CLIENT (pointing at itself) so ServicesModule-resolved + // services with the write safety net resolve; local engine writes bypass it entirely. + builder.RegisterModule(new ProtectedDataBrokerClientModule()); + // ...plus the ONE registration no other host may load: the real KMS adapter. Last wins // over ServicesModule's fail-closed NotConfigured placeholder. builder.RegisterModule(new ProtectedDataProviderModule()); diff --git a/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj b/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj index 4227c30db..4399e3e14 100644 --- a/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj +++ b/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj @@ -54,6 +54,7 @@ + diff --git a/Web/Resgrid.Web.Eventing/Startup.cs b/Web/Resgrid.Web.Eventing/Startup.cs index 4e3683dcf..1a6240998 100644 --- a/Web/Resgrid.Web.Eventing/Startup.cs +++ b/Web/Resgrid.Web.Eventing/Startup.cs @@ -351,6 +351,8 @@ public void ConfigureContainer(ContainerBuilder builder) // IncidentCommandService -> IncidentVoiceService) needs the weather and voip providers. builder.RegisterModule(new Resgrid.Providers.Voip.VoipProviderModule()); builder.RegisterModule(new Resgrid.Providers.Weather.WeatherProviderModule()); + // ADP broker CLIENT (no key material) — CallsService's write safety net resolves it. + builder.RegisterModule(new Resgrid.Providers.ProtectedData.ProtectedDataBrokerClientModule()); builder.RegisterType().As>().InstancePerLifetimeScope(); builder.RegisterType().As>().InstancePerLifetimeScope(); diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index 1f8a80240..413db1c34 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -144,6 +144,9 @@ private CancellationToken GetTtsPromptBudgetToken() [HttpGet("IncomingMessage")] [Produces("application/xml")] + // Twilio signature validation: without it anyone who learns a member's mobile number can + // forge inbound SMS and run text commands (respond, staffing, text-to-call) as that member. + [ValidateRequest] public async Task IncomingMessage([FromQuery] TwilioMessage request) { if (request == null || string.IsNullOrWhiteSpace(request.To) || string.IsNullOrWhiteSpace(request.From) || string.IsNullOrWhiteSpace(request.Body)) @@ -1127,6 +1130,7 @@ private async Task GetVoiceVerificationErrorResult() [HttpGet("InboundVoiceAction")] [Produces("application/xml")] + [ValidateRequest] public async Task InboundVoiceAction(string userId, [FromQuery] VoiceRequest twilioRequest, [FromQuery] string retry = null) { var response = new VoiceResponse(); @@ -1382,6 +1386,7 @@ private async Task IsPromptAudioReadyAsync(string text, int? departmentId) [HttpGet("InboundVoiceActionStatus")] [Produces("application/xml")] + [ValidateRequest] public async Task InboundVoiceActionStatus(string userId, [FromQuery] VoiceRequest twilioRequest) { var response = new VoiceResponse(); @@ -1424,6 +1429,7 @@ public async Task InboundVoiceActionStatus(string userId, [FromQue [HttpGet("InboundVoiceActionStaffing")] [Produces("application/xml")] + [ValidateRequest] public async Task InboundVoiceActionStaffing(string userId, [FromQuery] VoiceRequest twilioRequest) { var response = new VoiceResponse(); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs index 6d145e97b..4c660854e 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs @@ -31,11 +31,16 @@ public class CallFilesController : V4AuthenticatedApiControllerbaseSystemAuth #region Members and Constructors private readonly ICallsService _callsService; private readonly IDepartmentsService _departmentsService; + private readonly IProtectedReadService _protectedCallReadService; + private readonly IProtectedWriteService _protectedWriteService; - public CallFilesController(ICallsService callsService, IDepartmentsService departmentsService) + public CallFilesController(ICallsService callsService, IDepartmentsService departmentsService, + IProtectedReadService protectedCallReadService, IProtectedWriteService protectedWriteService) { _callsService = callsService; _departmentsService = departmentsService; + _protectedCallReadService = protectedCallReadService; + _protectedWriteService = protectedWriteService; } #endregion Members and Constructors @@ -66,14 +71,29 @@ public async Task> GetFilesForCall(int callId, boo var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); call = await _callsService.PopulateCallData(call, false, true, false, false, false, false, false, false, false); + // Attended protected read (plan 7.1): attachment names/coordinates decrypt with a valid + // grant or read as REDACTED/null. includeData additionally decrypts the binary payload + // through the broker; a concealed payload serializes as null, never ciphertext bytes. + var protectedRead = await _protectedCallReadService.ResolveAttachmentsForReadAsync(DepartmentId, + call.Attachments?.ToList(), Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId, + includeData: includeData); + if (call.Attachments != null && call.Attachments.Any()) { foreach (var attachment in call.Attachments) { + CallFileResultData fileData = null; if (type == 0) - result.Data.Add(ConvertCallFileData(attachment, department, includeData)); + fileData = ConvertCallFileData(attachment, department, includeData); else if (type == attachment.CallAttachmentType) - result.Data.Add(ConvertCallFileData(attachment, department, includeData)); + fileData = ConvertCallFileData(attachment, department, includeData); + + if (fileData != null) + { + fileData.IsProtected = protectedRead.IsProtected; + fileData.ProtectedReason = protectedRead.ProtectedReason; + result.Data.Add(fileData); + } } result.PageSize = result.Data.Count; @@ -104,19 +124,24 @@ public async Task GetFile(string query) if (String.IsNullOrWhiteSpace(query)) return NotFound(); - var decodedQuery = Encoding.UTF8.GetString(Convert.FromBase64String(query)); - - var decryptedQuery = SymmetricEncryption.Decrypt(decodedQuery, Config.SystemBehaviorConfig.ExternalLinkUrlParamPassphrase); - - var items = decryptedQuery.Split(char.Parse("|")); - - if (String.IsNullOrWhiteSpace(items[0]) || items[0] == "0" || String.IsNullOrWhiteSpace(items[1])) + string decryptedQuery; + try + { + var decodedQuery = Encoding.UTF8.GetString(Convert.FromBase64String(query)); + decryptedQuery = SymmetricEncryption.Decrypt(decodedQuery, Config.SystemBehaviorConfig.ExternalLinkUrlParamPassphrase); + } + catch (Exception) + { + // Malformed/foreign query: value-free not-found, never a 500. return NotFound(); + } - int departmentId = int.Parse(items[0].Trim()); - string id = items[1].Trim(); + // Expiry-aware signed link (legacy no-expiry links honored only while the transition + // flag allows them). + if (!TryValidateSignedFileQuery(decryptedQuery, out int departmentId, out int attachmentId)) + return NotFound(); - var attachment = await _callsService.GetCallAttachmentAsync(int.Parse(id)); + var attachment = await _callsService.GetCallAttachmentAsync(attachmentId); if (attachment == null) return NotFound(); @@ -128,6 +153,14 @@ public async Task GetFile(string query) if (String.IsNullOrWhiteSpace(attachment.FileName) || attachment.Data == null || attachment.Data.Length == 0) return NotFound(); + // ADP: this is an ANONYMOUS signed-link route — it can never carry a grant, so a + // protected department's enveloped attachment is simply not available here (and + // ciphertext bytes are never served). Attended clients fetch through GetFilesForCall + // with their grant instead. + if (Resgrid.Services.ProtectedReadService.IsBinaryEnveloped(attachment.Data) || + ProtectedDataEnvelope.HasEnvelopePrefix(attachment.FileName)) + return NotFound(); + var extension = Path.GetExtension(attachment.FileName).ToLowerInvariant(); var contentType = FileHelper.GetContentTypeByExtension(extension); @@ -151,19 +184,22 @@ public async Task GetFileHead(string query) if (String.IsNullOrWhiteSpace(query)) return NotFound(); - var decodedQuery = Encoding.UTF8.GetString(Convert.FromBase64String(query)); - - var decryptedQuery = SymmetricEncryption.Decrypt(decodedQuery, Config.SystemBehaviorConfig.ExternalLinkUrlParamPassphrase); - - var items = decryptedQuery.Split(char.Parse("|")); - - if (String.IsNullOrWhiteSpace(items[0]) || items[0] == "0" || String.IsNullOrWhiteSpace(items[1])) + string decryptedQuery; + try + { + var decodedQuery = Encoding.UTF8.GetString(Convert.FromBase64String(query)); + decryptedQuery = SymmetricEncryption.Decrypt(decodedQuery, Config.SystemBehaviorConfig.ExternalLinkUrlParamPassphrase); + } + catch (Exception) + { + // Malformed/foreign query: value-free not-found, never a 500. return NotFound(); + } - int departmentId = int.Parse(items[0].Trim()); - string id = items[1].Trim(); + if (!TryValidateSignedFileQuery(decryptedQuery, out int departmentId, out int attachmentId)) + return NotFound(); - var attachment = await _callsService.GetCallAttachmentAsync(int.Parse(id)); + var attachment = await _callsService.GetCallAttachmentAsync(attachmentId); if (attachment == null) return NotFound(); @@ -171,6 +207,11 @@ public async Task GetFileHead(string query) if (String.IsNullOrWhiteSpace(attachment.FileName) || attachment.Data == null || attachment.Data.Length == 0) return NotFound(); + // Same ADP rule as the GET route: anonymous links never see protected attachments. + if (Resgrid.Services.ProtectedReadService.IsBinaryEnveloped(attachment.Data) || + ProtectedDataEnvelope.HasEnvelopePrefix(attachment.FileName)) + return NotFound(); + var call = await _callsService.GetCallByIdAsync(attachment.CallId); if (call.DepartmentId != departmentId) return Unauthorized(); @@ -247,8 +288,31 @@ public async Task> SaveCallFile(SaveCallFileInp callAttachment.Longitude = decimal.Parse(input.Longitude); } + // ADP write preflight (plan 3.3): refuse BEFORE inserting the transient plaintext row. + var writePreflight = await _protectedWriteService.PreflightWriteAsync(call.DepartmentId, + Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId, IsSystemApiKeyRequest, cancellationToken); + if (!writePreflight.Success) + return Problem(type: writePreflight.Reason, + title: "Recent multi-factor verification is required to modify protected data.", + statusCode: StatusCodes.Status403Forbidden); + var saved = await _callsService.SaveCallAttachmentAsync(callAttachment, cancellationToken); + // ADP two-phase write (plan 19.2): encrypt names/coordinates AND the rgdpb binary + // payload now that the identity pk (an AAD component) exists, then persist the + // enveloped row. + var protectedWrite = await _protectedWriteService.PrepareCallAttachmentWriteAsync(call.DepartmentId, saved, + Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId, IsSystemApiKeyRequest, cancellationToken); + if (!protectedWrite.Success) + { + Resgrid.Framework.Logging.LogError($"ADP protected write failed AFTER insert for call attachment {saved.CallAttachmentId} in department {call.DepartmentId} ({protectedWrite.Reason}); transient plaintext row pending re-encryption."); + return Problem(type: protectedWrite.Reason, + title: "Protected storage is temporarily unavailable; the change was not saved.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + if (protectedWrite.IsProtected) + saved = await _callsService.SaveCallAttachmentAsync(saved, cancellationToken); + result.Id = saved.CallAttachmentId.ToString(); result.PageSize = 0; @@ -258,6 +322,42 @@ public async Task> SaveCallFile(SaveCallFileInp return CreatedAtAction(nameof(GetFile), new { departmentId = call.DepartmentId, id = saved.CallAttachmentId }, result); } + /// + /// Validates the decrypted signed-link payload: "dept|attachmentId" (legacy, accepted only + /// while SecurityConfig.AllowLegacySignedFileLinks) or "dept|attachmentId|expiresUtcTicks" + /// (current). An expired or malformed link reads as not-found — value-free. + /// + public static bool TryValidateSignedFileQuery(string decryptedQuery, out int departmentId, out int attachmentId) + { + departmentId = 0; + attachmentId = 0; + + if (String.IsNullOrWhiteSpace(decryptedQuery)) + return false; + + var items = decryptedQuery.Split(char.Parse("|")); + if (items.Length < 2 || String.IsNullOrWhiteSpace(items[0]) || items[0].Trim() == "0" || String.IsNullOrWhiteSpace(items[1])) + return false; + + if (!int.TryParse(items[0].Trim(), out departmentId) || !int.TryParse(items[1].Trim(), out attachmentId)) + return false; + + if (items.Length >= 3) + { + if (!long.TryParse(items[2].Trim(), out var expiresTicks)) + return false; + + if (DateTime.UtcNow.Ticks > expiresTicks) + return false; + } + else if (!Config.SecurityConfig.AllowLegacySignedFileLinks) + { + return false; + } + + return true; + } + public static CallFileResultData ConvertCallFileData(CallAttachment attachment, Department department, bool includeData) { var file = new CallFileResultData(); @@ -266,7 +366,10 @@ public static CallFileResultData ConvertCallFileData(CallAttachment attachment, file.FileName = attachment.FileName; file.Type = attachment.CallAttachmentType; - var query = SymmetricEncryption.Encrypt($"{department.DepartmentId}|{attachment.CallAttachmentId}", Config.SystemBehaviorConfig.ExternalLinkUrlParamPassphrase); + // Signed anonymous link WITH EXPIRY (third segment, UTC ticks): links regenerate on + // every authenticated list call, so a leaked URL stops working after the TTL. + var expiresTicks = DateTime.UtcNow.AddMinutes(Math.Max(5, Config.SecurityConfig.SignedFileLinkTtlMinutes)).Ticks; + var query = SymmetricEncryption.Encrypt($"{department.DepartmentId}|{attachment.CallAttachmentId}|{expiresTicks}", Config.SystemBehaviorConfig.ExternalLinkUrlParamPassphrase); file.Url = Config.SystemBehaviorConfig.ResgridApiBaseUrl + "/api/v4/CallFiles/GetFile?query=" + Convert.ToBase64String(Encoding.UTF8.GetBytes(query)); file.Name = attachment.Name; @@ -281,7 +384,9 @@ public static CallFileResultData ConvertCallFileData(CallAttachment attachment, if (!String.IsNullOrWhiteSpace(attachment.UserId)) file.UserId = attachment.UserId; - if (includeData) + // Null after a protected-read redaction (or when metadata-only resolution stripped the + // ciphertext) — a concealed payload is simply absent. + if (includeData && attachment.Data != null) file.Data = Convert.ToBase64String(attachment.Data); return file; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallNotesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallNotesController.cs index 0837b96b7..5d2da8e3f 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallNotesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallNotesController.cs @@ -29,11 +29,16 @@ public class CallNotesController : V4AuthenticatedApiControllerbase #region Members and Constructors private readonly ICallsService _callsService; private readonly IDepartmentsService _departmentsService; + private readonly IProtectedReadService _protectedCallReadService; + private readonly IProtectedWriteService _protectedWriteService; - public CallNotesController(ICallsService callsService, IDepartmentsService departmentsService) + public CallNotesController(ICallsService callsService, IDepartmentsService departmentsService, + IProtectedReadService protectedCallReadService, IProtectedWriteService protectedWriteService) { _callsService = callsService; _departmentsService = departmentsService; + _protectedCallReadService = protectedCallReadService; + _protectedWriteService = protectedWriteService; } #endregion Members and Constructors @@ -67,6 +72,11 @@ public async Task> GetCallNotes(string callId) call = await _callsService.PopulateCallData(call, false, false, true, false, false, false, false, false, false); + // Attended protected read (plan 7.1): note text and companion coordinates decrypt with a + // valid grant or read as REDACTED/null — never an envelope. + var protectedRead = await _protectedCallReadService.ResolveNotesForReadAsync(DepartmentId, + call.CallNotes?.ToList(), Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId); + if (call.CallNotes != null && call.CallNotes.Any()) { foreach (var note in call.CallNotes) @@ -74,7 +84,10 @@ public async Task> GetCallNotes(string callId) var fullName = await UserHelper.GetFullNameForUser(note.UserId); - result.Data.Add(ConvertCallNote(note, fullName, department)); + var noteData = ConvertCallNote(note, fullName, department); + noteData.IsProtected = protectedRead.IsProtected; + noteData.ProtectedReason = protectedRead.ProtectedReason; + result.Data.Add(noteData); } result.PageSize = result.Data.Count; @@ -130,8 +143,31 @@ public async Task> SaveCallNote(SaveCallNoteInp note.Longitude = decimal.Parse(input.Longitude); } + // ADP write preflight (plan 3.3): refuse BEFORE inserting the transient plaintext row. + var writePreflight = await _protectedWriteService.PreflightWriteAsync(DepartmentId, + Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId, workloadCaller: false, cancellationToken); + if (!writePreflight.Success) + return Problem(type: writePreflight.Reason, + title: "Recent multi-factor verification is required to modify protected data.", + statusCode: StatusCodes.Status403Forbidden); + var saved = await _callsService.SaveCallNoteAsync(note, cancellationToken); + // ADP two-phase write (plan 19.2): the identity pk is an AAD component — encrypt now + // that the id exists, then persist the enveloped row (companion coordinates move into + // their envelope columns). + var protectedWrite = await _protectedWriteService.PrepareCallNoteWriteAsync(DepartmentId, saved, + Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId, workloadCaller: false, cancellationToken); + if (!protectedWrite.Success) + { + Resgrid.Framework.Logging.LogError($"ADP protected write failed AFTER insert for call note {saved.CallNoteId} in department {DepartmentId} ({protectedWrite.Reason}); transient plaintext row pending re-encryption."); + return Problem(type: protectedWrite.Reason, + title: "Protected storage is temporarily unavailable; the change was not saved.", + statusCode: StatusCodes.Status503ServiceUnavailable); + } + if (protectedWrite.IsProtected) + saved = await _callsService.SaveCallNoteAsync(saved, cancellationToken); + result.Id = saved.CallNoteId.ToString(); result.PageSize = 0; result.Status = ResponseHelper.Created; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 0a55352e4..28f90cad8 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -60,6 +60,8 @@ public class CallsController : V4AuthenticatedApiControllerbaseSystemAuth private readonly IDispatchRecommendationService _dispatchRecommendationService; private readonly IFeatureToggleService _featureToggleService; private readonly IDepartmentDataProtectionService _dataProtectionService; + private readonly IProtectedReadService _protectedCallReadService; + private readonly IProtectedWriteService _protectedWriteService; public CallsController( ICallsService callsService, @@ -85,10 +87,14 @@ public CallsController( ICallDispatchStatusService callDispatchStatusService, IDispatchRecommendationService dispatchRecommendationService, IFeatureToggleService featureToggleService, - IDepartmentDataProtectionService dataProtectionService + IDepartmentDataProtectionService dataProtectionService, + IProtectedReadService protectedCallReadService, + IProtectedWriteService protectedWriteService ) { _dataProtectionService = dataProtectionService; + _protectedCallReadService = protectedCallReadService; + _protectedWriteService = protectedWriteService; _callsService = callsService; _departmentsService = departmentsService; _userProfileService = userProfileService; @@ -171,6 +177,47 @@ private async Task ApplyBigBoardSafeShellAsync(IEnumerable return true; } + /// The caller's Protected Data Grant, when presented (plan section 3.1 step 6). + private string ProtectedGrantToken => Request.Headers[DataProtectionController.GrantHeader].ToString(); + + /// + /// Attended protected-read resolution (plan section 7.1), run BEFORE ConvertCall so the DTO + /// carries broker-decrypted plaintext (valid grant) or the exact REDACTED placeholder — + /// never ciphertext. One broker round trip per request. BigBoard sessions still get the + /// safe shell afterwards, which strips everything regardless. + /// + private async Task> ResolveProtectedReadsAsync(IReadOnlyList calls) + { + var results = await _protectedCallReadService.ResolveForReadAsync(DepartmentId, calls, ProtectedGrantToken, UserId); + + var map = new Dictionary(); + foreach (var read in results) + { + if (read.Call != null) + map[read.Call.CallId] = read; + } + + return map; + } + + /// Maps a blocked protected write to its value-free problem response (plan 3.3/19.2). + private ObjectResult ProtectedWriteProblem(ProtectedWriteResult write) => + Problem(type: write.Reason, + title: write.Reason == "broker_unavailable" + ? "Protected storage is temporarily unavailable; the change was not saved." + : "Recent multi-factor verification is required to modify protected data.", + statusCode: write.Reason == "broker_unavailable" ? StatusCodes.Status503ServiceUnavailable : StatusCodes.Status403Forbidden); + + private static void ApplyProtectedReadMetadata(CallResultData data, ProtectedReadResult read) + { + if (data == null || read == null) + return; + + data.IsProtected = read.IsProtected; + data.RedactedFields = read.RedactedFields ?? new List(); + data.ProtectedReason = read.ProtectedReason; + } + /// /// Returns all the active calls for the department /// @@ -182,12 +229,16 @@ public async Task> GetActiveCalls() { var result = new ActiveCallsResult(); - var calls = (await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId)).OrderByDescending(x => x.LoggedOn); + var calls = (await _callsService.GetActiveCallsByDepartmentAsync(DepartmentId)).OrderByDescending(x => x.LoggedOn).ToList(); var destinationPois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); var destinationPoiLookup = destinationPois.ToDictionary(x => x.PoiId); if (calls != null && calls.Any()) { + // Resolve BEFORE per-call processing so geocoding and templates see plaintext (or + // REDACTED), never envelopes. + var protectedReads = await ResolveProtectedReadsAsync(calls); + foreach (var c in calls) { var callWithData = await _callsService.PopulateCallData(c, false, true, true, false, false, false, true, true, true); @@ -204,7 +255,10 @@ public async Task> GetActiveCalls() address = c.Address; destinationPoiLookup.TryGetValue(callWithData.DestinationPoiId.GetValueOrDefault(), out var destinationPoi); - result.Data.Add(ConvertCall(callWithData, null, address, TimeZone, destinationPoi)); + var callData = ConvertCall(callWithData, null, address, TimeZone, destinationPoi); + if (protectedReads.TryGetValue(callWithData.CallId, out var protectedRead)) + ApplyProtectedReadMetadata(callData, protectedRead); + result.Data.Add(callData); } await ApplyBigBoardSafeShellAsync(result.Data); @@ -252,6 +306,13 @@ public async Task> GetCall(string callId, [FromQuery return Unauthorized(); c = await _callsService.PopulateCallData(c, false, true, true, false, false, false, true, true, true); + + // Attended protected read (plan 7.1), AFTER populate so loaded notes/attachments resolve + // in the same batch, and BEFORE geocoding, protocols and conversion. A system API key + // carries no grant, so protected values stay REDACTED for it by construction (plan 3.4). + var protectedRead = await _protectedCallReadService.ResolveForReadAsync(effectiveDepartmentId, c, + ProtectedGrantToken, UserId); + var destinationPoi = await GetValidatedDestinationPoiAsync(c.DestinationPoiId, effectiveDepartmentId); string address = ""; @@ -277,6 +338,7 @@ public async Task> GetCall(string callId, [FromQuery } result.Data = ConvertCall(c, protocols, address, TimeZone, destinationPoi); + ApplyProtectedReadMetadata(result.Data, protectedRead); // BigBoard shells also suppress UDF submissions — user-authored free text defaults to // sensitive in a protected department (plan section 5.2). @@ -338,6 +400,10 @@ public async Task> GetCallExtraData(int callId call = await _callsService.PopulateCallData(call, true, true, true, true, true, true, true, true, true); + // Attended protected read (plan 7.1): CallFormData is a cataloged field — decrypt with a + // valid grant or hand back REDACTED, never an envelope. + await _protectedCallReadService.ResolveForReadAsync(DepartmentId, call, ProtectedGrantToken, UserId); + // BigBoard step-down: dispatched unit/personnel state below is allowlisted resource // state, but submitted call form data is protected content (plan section 7.3). if (IsBigBoardSession && await _dataProtectionService.IsProtectionEnforcedAsync(DepartmentId)) @@ -907,6 +973,13 @@ public async Task> SaveCall([FromBody] NewCallInput if (shouldDispatchNow && await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.DispatchRunCards, effectiveDepartmentId)) recommendationResult = await _dispatchRecommendationService.EnrichCallForDispatchAsync(call, 1, true, cancellationToken); + // ADP write preflight (plan 3.3): refuse BEFORE inserting — attended callers need a + // current grant; system-key/workload callers pass (broker encrypt-only lane). + var writePreflight = await _protectedWriteService.PreflightWriteAsync(effectiveDepartmentId, + ProtectedGrantToken, UserId, IsSystemApiKeyRequest, cancellationToken); + if (!writePreflight.Success) + return ProtectedWriteProblem(writePreflight); + var savedCall = await _callsService.SaveCallAsync(call, cancellationToken); if (recommendationResult != null && recommendationResult.MatchedRunCardId.HasValue && recommendationResult.AutoDispatch) @@ -915,6 +988,21 @@ public async Task> SaveCall([FromBody] NewCallInput // Attach weather alerts as call notes if enabled await _weatherAlertService.AttachWeatherAlertsToCallAsync(savedCall, cancellationToken); + // ADP two-phase write (plan 19.2): the identity pk is an AAD component, so encryption + // runs after the insert assigned it, then the enveloped row is persisted. A failure here + // leaves a LOGGED transient-plaintext row and fails the request (the next migration + // sweep also envelopes it); downstream broadcast uses the enveloped call, which the + // notification safe-projections turn into generic content. + var protectedWrite = await _protectedWriteService.PrepareCallWriteAsync(effectiveDepartmentId, savedCall, + null, ProtectedGrantToken, UserId, IsSystemApiKeyRequest, cancellationToken); + if (!protectedWrite.Success) + { + Logging.LogError($"ADP protected write failed AFTER insert for call {savedCall.CallId} in department {effectiveDepartmentId} ({protectedWrite.Reason}); transient plaintext row pending re-encryption."); + return ProtectedWriteProblem(protectedWrite); + } + if (protectedWrite.IsProtected) + savedCall = await _callsService.SaveCallAsync(savedCall, cancellationToken); + //OutboundEventProvider handler = new OutboundEventProvider.CallAddedTopicHandler(); //OutboundEventProvider..Handle(new CallAddedEvent() { DepartmentId = DepartmentId, Call = savedCall }); _eventAggregator.SendMessage(new CallAddedEvent() { DepartmentId = effectiveDepartmentId, Call = savedCall }); @@ -1044,6 +1132,11 @@ public async Task> EditCall([FromBody] EditCallInpu if (editCallInput.DestinationPoiId.HasValue && editCallInput.DestinationPoiId.Value > 0 && destinationPoi == null) return BadRequest(); + // ADP: snapshot the stored cataloged values BEFORE the input overwrites them, so a + // round-tripped REDACTED sentinel restores the stored envelope instead of persisting + // the literal placeholder. + var storedCatalogedValues = Resgrid.Services.ProtectedReadService.SnapshotCatalogedCallFields(call); + call.Priority = editCallInput.Priority; call.Name = editCallInput.Name; call.NatureOfCall = editCallInput.Nature; @@ -1272,6 +1365,13 @@ public async Task> EditCall([FromBody] EditCallInpu if (call.DispatchOn.HasValue && call.DispatchOn.Value <= DateTime.UtcNow) call.HasBeenDispatched = true; + // ADP protected write (plan 19.2): cataloged plaintext encrypts in place before + // persistence; a blocked write persists nothing. + var protectedWrite = await _protectedWriteService.PrepareCallWriteAsync(DepartmentId, call, + storedCatalogedValues, ProtectedGrantToken, UserId, IsSystemApiKeyRequest, cancellationToken); + if (!protectedWrite.Success) + return ProtectedWriteProblem(protectedWrite); + await _callsService.SaveCallAsync(call, cancellationToken); // Attach weather alerts as call notes if enabled (deduplication handled inside) @@ -1681,6 +1781,12 @@ public async Task> CloseCall([FromBody] CloseCallI call.CompletedNotes = closeCallInput.Notes; call.State = closeCallInput.Type; + // ADP protected write (plan 19.2): CompletedNotes is cataloged; encrypt before persisting. + var protectedWrite = await _protectedWriteService.PrepareCallWriteAsync(DepartmentId, call, + null, ProtectedGrantToken, UserId, IsSystemApiKeyRequest, cancellationToken); + if (!protectedWrite.Success) + return ProtectedWriteProblem(protectedWrite); + var savedCall = await _callsService.SaveCallAsync(call, cancellationToken); _eventAggregator.SendMessage(new CallClosedEvent() { DepartmentId = DepartmentId, Call = savedCall }); @@ -1710,13 +1816,15 @@ public async Task> GetAllPendingScheduledCall { var result = new ScheduledCallsResult(); - var calls = (await _callsService.GetAllNonDispatchedScheduledCallsByDepartmentIdAsync(DepartmentId)).OrderBy(x => x.DispatchOn); + var calls = (await _callsService.GetAllNonDispatchedScheduledCallsByDepartmentIdAsync(DepartmentId)).OrderBy(x => x.DispatchOn).ToList(); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var destinationPois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); var destinationPoiLookup = destinationPois.ToDictionary(x => x.PoiId); if (calls != null && calls.Any()) { + var protectedReads = await ResolveProtectedReadsAsync(calls); + foreach (var c in calls) { string address = ""; @@ -1731,7 +1839,10 @@ public async Task> GetAllPendingScheduledCall address = c.Address; destinationPoiLookup.TryGetValue(c.DestinationPoiId.GetValueOrDefault(), out var destinationPoi); - result.Data.Add(ConvertCall(c, null, address, TimeZone, destinationPoi)); + var callData = ConvertCall(c, null, address, TimeZone, destinationPoi); + if (protectedReads.TryGetValue(c.CallId, out var protectedRead)) + ApplyProtectedReadMetadata(callData, protectedRead); + result.Data.Add(callData); } await ApplyBigBoardSafeShellAsync(result.Data); @@ -1771,6 +1882,10 @@ public async Task> GetCallHistory(int callId) return Unauthorized(); call = await _callsService.PopulateCallData(call, true, true, true, true, true, true, true, true, true); + + // Attended protected read (plan 7.1): the history entries below embed note text — + // decrypt with a valid grant or embed REDACTED, never an envelope. + await _protectedCallReadService.ResolveForReadAsync(DepartmentId, call, ProtectedGrantToken, UserId); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); result.Data.Add(new CallHistoryResultData() @@ -1982,12 +2097,14 @@ public async Task> GetCalls(DateTime startDate, var result = new ActiveCallsResult(); - var calls = (await _callsService.GetAllCallsByDepartmentDateRangeAsync(DepartmentId, startDate, endDate)).OrderByDescending(x => x.LoggedOn); + var calls = (await _callsService.GetAllCallsByDepartmentDateRangeAsync(DepartmentId, startDate, endDate)).OrderByDescending(x => x.LoggedOn).ToList(); var destinationPois = await _mappingService.GetPOIsForDepartmentAsync(DepartmentId); var destinationPoiLookup = destinationPois.ToDictionary(x => x.PoiId); if (calls != null && calls.Any()) { + var protectedReads = await ResolveProtectedReadsAsync(calls); + foreach (var c in calls) { var callWithData = await _callsService.PopulateCallData(c, false, true, true, false, false, false, true, true, true); @@ -2010,7 +2127,10 @@ public async Task> GetCalls(DateTime startDate, address = c.Address; destinationPoiLookup.TryGetValue(callWithData.DestinationPoiId.GetValueOrDefault(), out var destinationPoi); - result.Data.Add(ConvertCall(callWithData, null, address, TimeZone, destinationPoi)); + var callData = ConvertCall(callWithData, null, address, TimeZone, destinationPoi); + if (protectedReads.TryGetValue(callWithData.CallId, out var protectedRead)) + ApplyProtectedReadMetadata(callData, protectedRead); + result.Data.Add(callData); } await ApplyBigBoardSafeShellAsync(result.Data); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs index 3f571409d..4d6152380 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestResponseController.cs @@ -73,6 +73,10 @@ public async Task EmailConfirm(string token) /// [HttpGet("VoiceCall")] [AllowAnonymous] + // Twilio is the ONLY caller of this URL (it is only ever handed to the Twilio Calls API as + // the call Url), so its signature is required — that stops response-token probing by + // anything that is not Twilio. + [ValidateRequest] [ProducesResponseType(StatusCodes.Status200OK)] public async Task VoiceCall(string token) { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestsController.cs index 2b57e5cb0..49c89c83e 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CommunicationTestsController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Resgrid.Framework; @@ -415,6 +415,10 @@ public async Task GetReport(string runId) ContactCarrier = r.ContactCarrier, VerificationStatus = r.VerificationStatus, VerificationStatusText = r.GetVerificationDisplayText(), + ChannelEnabled = r.ChannelEnabled, + StaffingLevel = r.StaffingLevel, + StaffingLevelText = r.GetStaffingLevelDisplayText(), + Suppressed = r.Suppressed, SendAttempted = r.SendAttempted, SendSucceeded = r.SendSucceeded, SentOn = r.SentOn?.ToString("O"), diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs index ca1f4b5fd..14c5fe8e8 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs @@ -32,6 +32,7 @@ public class ContactsController : V4AuthenticatedApiControllerbase private readonly Model.Services.IAuthorizationService _authorizationService; private readonly IEventAggregator _eventAggregator; private readonly IUserDefinedFieldsService _userDefinedFieldsService; + private readonly IProtectedReadService _protectedReadService; public ContactsController( IContactsService contactsService, @@ -39,9 +40,11 @@ public ContactsController( IUserProfileService userProfileService, Model.Services.IAuthorizationService authorizationService, IEventAggregator eventAggregator, - IUserDefinedFieldsService userDefinedFieldsService + IUserDefinedFieldsService userDefinedFieldsService, + IProtectedReadService protectedReadService ) { + _protectedReadService = protectedReadService; _contactsService = contactsService; _departmentsService = departmentsService; _userProfileService = userProfileService; @@ -108,6 +111,11 @@ public async Task> GetAllContacts() if (contacts != null && contacts.Any()) { + // Attended protected read (plan 7.1): one broker round trip for the whole list; + // without a valid grant, cataloged fields read as REDACTED — never envelopes. + var protectedRead = await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, + contacts.ToList(), Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId); + foreach (var contact in contacts) { var addedOnPerson = await _userProfileService.GetProfileByUserIdAsync(contact.AddedByUserId); @@ -116,7 +124,10 @@ public async Task> GetAllContacts() if (!String.IsNullOrWhiteSpace(contact.EditedByUserId)) editedPerson = await _userProfileService.GetProfileByUserIdAsync(contact.AddedByUserId); - result.Data.Add(ConvertContactData(contact, department, addedOnPerson, editedPerson)); + var contactData = ConvertContactData(contact, department, addedOnPerson, editedPerson); + contactData.IsProtected = protectedRead.IsProtected; + contactData.ProtectedReason = protectedRead.ProtectedReason; + result.Data.Add(contactData); } result.PageSize = result.Data.Count; @@ -149,6 +160,10 @@ public async Task> GetContactById(string contactId) if (contact != null && contact.DepartmentId == DepartmentId) { + // Attended protected read (plan 7.1): decrypt-or-redact before conversion. + var protectedRead = await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, + new[] { contact }, Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId); + var addedOnPerson = await _userProfileService.GetProfileByUserIdAsync(contact.AddedByUserId); UserProfile editedPerson = null; @@ -156,6 +171,9 @@ public async Task> GetContactById(string contactId) editedPerson = await _userProfileService.GetProfileByUserIdAsync(contact.AddedByUserId); result.Data = ConvertContactData(contact, department, addedOnPerson, editedPerson); + result.Data.IsProtected = protectedRead.IsProtected; + result.Data.ProtectedReason = protectedRead.ProtectedReason; + result.Data.RedactedFields = protectedRead.RedactedFields; var udfValues = await _userDefinedFieldsService.GetFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Contact, contactId); if (udfValues != null && udfValues.Any()) @@ -210,6 +228,11 @@ public async Task> GetContactNotesByContactId(s { var contactNotes = await _contactsService.GetContactNotesByContactIdAsync(contactId, Int32.MaxValue, false); + // Attended protected read (plan 7.1): note text decrypts with a valid grant or reads + // as REDACTED — never an envelope. + var protectedRead = await _protectedReadService.ResolveContactNotesForReadAsync(DepartmentId, + contactNotes?.ToList(), Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId); + foreach (var contactNote in contactNotes) { var addedOnPerson = await _userProfileService.GetProfileByUserIdAsync(contactNote.AddedByUserId); @@ -222,7 +245,10 @@ public async Task> GetContactNotesByContactId(s if (!String.IsNullOrWhiteSpace(contactNote.ContactNoteTypeId)) noteType = await _contactsService.GetContactNoteTypeByIdAsync(contactNote.ContactNoteTypeId); - result.Data.Add(ConvertContactNoteData(contactNote, noteType, department, addedOnPerson, editedPerson)); + var noteData = ConvertContactNoteData(contactNote, noteType, department, addedOnPerson, editedPerson); + noteData.IsProtected = protectedRead.IsProtected; + noteData.ProtectedReason = protectedRead.ProtectedReason; + result.Data.Add(noteData); } result.PageSize = contactNotes.Count; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/FeedsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/FeedsController.cs index 684f68b9a..5e4b9edda 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/FeedsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/FeedsController.cs @@ -57,7 +57,19 @@ public async Task GetActiveCallsAsRSS(string key) feed.Authors.Add(new SyndicationPerson("team@resgrid.com")); feed.Categories.Add(new SyndicationCategory("Resgrid Calls")); feed.Description = new TextSyndicationContent(string.Format("The active calls for the department {0}", department.Name)); - feed.Items = calls.Select(call => new SyndicationItem(call.Name, call.NatureOfCall, new Uri($"{Config.SystemBehaviorConfig.ResgridBaseUrl}/User/Dispatch/ViewCall?callId=" + call.CallId), call.CallId.ToString(), call.LoggedOn)).ToList(); + + // ADP egress (plan 5.5): this is an anonymous integration feed — protected departments' + // enveloped values must never leave as content (nor as ciphertext). Generic titles keep + // the feed functional; details require signing in. + feed.Items = calls.Select(call => new SyndicationItem( + Resgrid.Model.ProtectedDataEnvelope.HasEnvelopePrefix(call.Name) + ? $"Protected dispatch {call.Number}" + : call.Name, + Resgrid.Model.ProtectedDataEnvelope.HasEnvelopePrefix(call.NatureOfCall) + ? "A protected dispatch is available. Sign in to Resgrid to view details." + : call.NatureOfCall, + new Uri($"{Config.SystemBehaviorConfig.ResgridBaseUrl}/User/Dispatch/ViewCall?callId=" + call.CallId), + call.CallId.ToString(), call.LoggedOn)).ToList(); var settings = new XmlWriterSettings diff --git a/Web/Resgrid.Web.Services/Models/v4/CallFiles/CallFileResult.cs b/Web/Resgrid.Web.Services/Models/v4/CallFiles/CallFileResult.cs index bb4878cf4..9471d46ea 100644 --- a/Web/Resgrid.Web.Services/Models/v4/CallFiles/CallFileResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/CallFiles/CallFileResult.cs @@ -16,6 +16,18 @@ public class CallFileResult : StandardApiResponseV4Base /// public class CallFileResultData { + /// + /// ADP: true when this row belongs to a protection-enforced department (shield indicator). + /// Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + /// — never ciphertext. + /// + public bool IsProtected { get; set; } + + /// ADP: machine-readable reason when values are redacted (step_up_required, + /// grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + /// nothing is redacted. + public string ProtectedReason { get; set; } + /// /// Id of the call file /// diff --git a/Web/Resgrid.Web.Services/Models/v4/CallNotes/CallNotesResult.cs b/Web/Resgrid.Web.Services/Models/v4/CallNotes/CallNotesResult.cs index 0aea7c93d..c05f65160 100644 --- a/Web/Resgrid.Web.Services/Models/v4/CallNotes/CallNotesResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/CallNotes/CallNotesResult.cs @@ -30,6 +30,18 @@ public CallNotesResult() /// public class CallNoteResultData { + /// + /// ADP: true when this row belongs to a protection-enforced department (shield indicator). + /// Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + /// — never ciphertext. + /// + public bool IsProtected { get; set; } + + /// ADP: machine-readable reason when values are redacted (step_up_required, + /// grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + /// nothing is redacted. + public string ProtectedReason { get; set; } + /// /// Call Id of the Note /// diff --git a/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs b/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs index 44dd9fb0f..7f5bae0f8 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Calls/CallResult.cs @@ -29,6 +29,24 @@ public class CallResultData /// public string CallId { get; set; } + /// + /// ADP: true when this call belongs to a protection-enforced department (clients render the + /// protected-field shield). Values in this DTO are then broker-decrypted plaintext or the + /// exact "REDACTED" placeholder — never ciphertext. + /// + public bool IsProtected { get; set; } + + /// ADP: stable catalog field ids ("calls.natureofcall") whose values are REDACTED. + public List RedactedFields { get; set; } = new List(); + + /// + /// ADP: machine-readable reason when fields are redacted — step_up_required, grant_expired, + /// grant_revoked, protected_access_denied, or broker_unavailable. Clients map + /// step_up_required/grant_expired onto the step-up (VerifyStepUp) flow. Null when nothing is + /// redacted. + /// + public string ProtectedReason { get; set; } + //public string Unm { get; set; } /// diff --git a/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunReportResult.cs b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunReportResult.cs index 7ca26b65e..9c7ebfa12 100644 --- a/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunReportResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunReportResult.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; namespace Resgrid.Web.Services.Models.v4.CommunicationTests; @@ -39,6 +39,31 @@ public class CommunicationTestResultData /// "Grandfathered", or "N/A" for push. Supplied so every client shows the same wording. /// public string VerificationStatusText { get; set; } + + /// + /// Whether the member had this channel switched on in their own notification settings when the + /// run was built. Null for runs built before the election was recorded — read the member's + /// current profile there rather than treating null as off. + /// + public bool? ChannelEnabled { get; set; } + + /// + /// The member's staffing level when the run was built, or null when they had never set one. + /// + public int? StaffingLevel { get; set; } + + /// + /// Display name for as the department had it configured at run time, + /// or the raw level when it is no longer configured. Empty when no level was recorded. + /// + public string StaffingLevelText { get; set; } + + /// + /// Whether the department's Suppress (Mute) Staffing Levels setting muted this member for this + /// run. A suppressed result was deliberately never sent, so it is not a delivery failure. + /// + public bool Suppressed { get; set; } + public bool SendAttempted { get; set; } public bool SendSucceeded { get; set; } public string SentOn { get; set; } diff --git a/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactNotesResult.cs b/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactNotesResult.cs index 3af2d8d0d..1757995fe 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactNotesResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactNotesResult.cs @@ -31,6 +31,18 @@ public ContactNotesResult() /// public class ContactNoteResultData { + /// + /// ADP: true when this row belongs to a protection-enforced department (shield indicator). + /// Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + /// — never ciphertext. + /// + public bool IsProtected { get; set; } + + /// ADP: machine-readable reason when values are redacted (step_up_required, + /// grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + /// nothing is redacted. + public string ProtectedReason { get; set; } + public string ContactNoteId { get; set; } public string ContactId { get; set; } diff --git a/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactResult.cs b/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactResult.cs index ce733b672..02c147d79 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactResult.cs @@ -24,6 +24,21 @@ public class ContactResult : StandardApiResponseV4Base /// public class ContactResultData { + /// + /// ADP: true when this row belongs to a protection-enforced department (shield indicator). + /// Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + /// — never ciphertext. + /// + public bool IsProtected { get; set; } + + /// ADP: machine-readable reason when values are redacted (step_up_required, + /// grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + /// nothing is redacted. + public string ProtectedReason { get; set; } + + /// ADP: stable catalog field ids ("contacts.email") whose values are REDACTED. + public List RedactedFields { get; set; } = new List(); + public string ContactId { get; set; } public int ContactType { get; set; } // 0 = Person, 1 = Company diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 633ce3f22..6b07229f8 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -242,6 +242,13 @@ The cancellation token that can be used by other objects or threads to receive notice of cancellation. + + + Validates the decrypted signed-link payload: "dept|attachmentId" (legacy, accepted only + while SecurityConfig.AllowLegacySignedFileLinks) or "dept|attachmentId|expiresUtcTicks" + (current). An expired or malformed link reads as not-found — value-free. + + Call Priorities, for example Low, Medium, High. Call Priorities can be system provided ones or custom for a department @@ -317,6 +324,20 @@ can never relax this. + + The caller's Protected Data Grant, when presented (plan section 3.1 step 6). + + + + Attended protected-read resolution (plan section 7.1), run BEFORE ConvertCall so the DTO + carries broker-decrypted plaintext (valid grant) or the exact REDACTED placeholder — + never ciphertext. One broker round trip per request. BigBoard sessions still get the + safe shell afterwards, which strips everything regardless. + + + + Maps a blocked protected write to its value-free problem response (plan 3.3/19.2). + Returns all the active calls for the department @@ -6210,6 +6231,18 @@ Object representing a file for a call in the Resgrid system + + + ADP: true when this row belongs to a protection-enforced department (shield indicator). + Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + — never ciphertext. + + + + ADP: machine-readable reason when values are redacted (step_up_required, + grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + nothing is redacted. + Id of the call file @@ -6336,6 +6369,18 @@ + + + ADP: true when this row belongs to a protection-enforced department (shield indicator). + Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + — never ciphertext. + + + + ADP: machine-readable reason when values are redacted (step_up_required, + grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + nothing is redacted. + Call Id of the Note @@ -6551,6 +6596,24 @@ Id of the call + + + ADP: true when this call belongs to a protection-enforced department (clients render the + protected-field shield). Values in this DTO are then broker-decrypted plaintext or the + exact "REDACTED" placeholder — never ciphertext. + + + + ADP: stable catalog field ids ("calls.natureofcall") whose values are REDACTED. + + + + ADP: machine-readable reason when fields are redacted — step_up_required, grant_expired, + grant_revoked, protected_access_denied, or broker_unavailable. Clients map + step_up_required/grant_expired onto the step-up (VerifyStepUp) flow. Null when nothing is + redacted. + + Priority of the call (Low = 0, Medium = 1, High = 2, Emergency = 3) @@ -7388,6 +7451,21 @@ A contact + + + ADP: true when this row belongs to a protection-enforced department (shield indicator). + Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + — never ciphertext. + + + + ADP: machine-readable reason when values are redacted (step_up_required, + grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + nothing is redacted. + + + ADP: stable catalog field ids ("contacts.email") whose values are REDACTED. + User Defined Field values for this contact @@ -9030,6 +9108,30 @@ "Grandfathered", or "N/A" for push. Supplied so every client shows the same wording. + + + Whether the member had this channel switched on in their own notification settings when the + run was built. Null for runs built before the election was recorded — read the member's + current profile there rather than treating null as off. + + + + + The member's staffing level when the run was built, or null when they had never set one. + + + + + Display name for as the department had it configured at run time, + or the raw level when it is no longer configured. Empty when no level was recorded. + + + + + Whether the department's Suppress (Mute) Staffing Levels setting muted this member for this + run. A suppressed result was deliberately never sent, so it is not a delivery failure. + + Result of getting test runs @@ -9372,6 +9474,18 @@ A contact note + + + ADP: true when this row belongs to a protection-enforced department (shield indicator). + Protected values here are broker-decrypted plaintext or the exact "REDACTED" placeholder + — never ciphertext. + + + + ADP: machine-readable reason when values are redacted (step_up_required, + grant_expired, grant_revoked, protected_access_denied, broker_unavailable); null when + nothing is redacted. + Gets the contact categories diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 12b3c93c7..0ea520eef 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -675,7 +675,6 @@ public void ConfigureContainer(ContainerBuilder builder) // ADP broker CLIENT only (no key material, no KMS route) — the app tier asks the broker // to act on a caller's grant. The real KMS adapter module is broker-host-only. builder.RegisterModule(new Resgrid.Providers.ProtectedData.ProtectedDataBrokerClientModule()); - builder.RegisterType().As>().InstancePerLifetimeScope(); builder.RegisterType().As>().InstancePerLifetimeScope(); builder.RegisterType>().As>().InstancePerLifetimeScope(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs index 92492615d..c2db98f74 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs @@ -39,11 +39,13 @@ public class ContactsController : SecureBaseController private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IRouteService _routeService; private readonly IPhoneNumberProcesserProvider _phoneNumberProcesser; + private readonly IProtectedReadService _protectedReadService; public ContactsController(IContactsService contactsService, IDepartmentsService departmentsService, IUserProfileService userProfileService, IAddressService addressService, IEventAggregator eventAggregator, ICallsService callsService, IAuthorizationService authorizationService, IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, - IDepartmentGroupsService departmentGroupsService, IRouteService routeService, IPhoneNumberProcesserProvider phoneNumberProcesser) + IDepartmentGroupsService departmentGroupsService, IRouteService routeService, IPhoneNumberProcesserProvider phoneNumberProcesser, + IProtectedReadService protectedReadService) { _contactsService = contactsService; _departmentsService = departmentsService; @@ -57,6 +59,7 @@ public ContactsController(IContactsService contactsService, IDepartmentsService _departmentGroupsService = departmentGroupsService; _routeService = routeService; _phoneNumberProcesser = phoneNumberProcesser; + _protectedReadService = protectedReadService; } #endregion Private Members and Constructors @@ -69,6 +72,19 @@ public async Task Index() model.Contacts = await _contactsService.GetAllContactsForDepartmentAsync(DepartmentId); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + // ADP: server-rendered lists show REDACTED for protected values (no grant server-side). + await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, model.Contacts, null, UserId); + + // The categories tree carries its own Contact collections (separate instances). + if (model.ContactCategories != null) + { + foreach (var category in model.ContactCategories) + { + if (category.Contacts != null && category.Contacts.Any()) + await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, category.Contacts.ToList(), null, UserId); + } + } + List trees = new List(); var tree0 = new BSTreeModel(); tree0.id = "TreeGroup_-1"; @@ -132,6 +148,12 @@ public async Task View(string contactId) model.Notes = await _contactsService.GetContactNotesByContactIdAsync(contactId, DepartmentId); + // ADP: render REDACTED; the reveal is client-side (step-up modal then RevealContact). + var protectedRead = await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, + new List { model.Contact }, null, UserId); + await _protectedReadService.ResolveContactNotesForReadAsync(DepartmentId, model.Notes, null, UserId); + model.IsProtectedContact = protectedRead.IsProtected; + model.RouteStops = await _routeService.GetRouteStopsForContactAsync(contactId, DepartmentId) ?? new List(); if (model.RouteStops.Count > 0) { @@ -389,21 +411,29 @@ public async Task Edit(string contactId) if (model.Contact.DepartmentId != DepartmentId) return Unauthorized(); - if (!String.IsNullOrWhiteSpace(model.Contact.EntranceGpsCoordinates)) + // ADP: the edit form renders protected values as the REDACTED sentinel; unchanged + // fields posted back are restored to their stored envelopes by the write safety net. + await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, + new List { model.Contact }, null, UserId); + + if (!String.IsNullOrWhiteSpace(model.Contact.EntranceGpsCoordinates) && + model.Contact.EntranceGpsCoordinates.Contains(',')) { var entranceGpsCoordinates = model.Contact.EntranceGpsCoordinates.Split(','); model.LocationGpsLatitude = entranceGpsCoordinates[0]; model.LocationGpsLongitude = entranceGpsCoordinates[1]; } - if (!String.IsNullOrWhiteSpace(model.Contact.LocationGpsCoordinates)) + if (!String.IsNullOrWhiteSpace(model.Contact.LocationGpsCoordinates) && + model.Contact.LocationGpsCoordinates.Contains(',')) { var locationGpsCoordinates = model.Contact.LocationGpsCoordinates.Split(','); model.LocationGpsLatitude = locationGpsCoordinates[0]; model.LocationGpsLongitude = locationGpsCoordinates[1]; } - if (!String.IsNullOrWhiteSpace(model.Contact.ExitGpsCoordinates)) + if (!String.IsNullOrWhiteSpace(model.Contact.ExitGpsCoordinates) && + model.Contact.ExitGpsCoordinates.Contains(',')) { var exitGpsCoordinates = model.Contact.ExitGpsCoordinates.Split(','); model.ExitGpsLatitude = exitGpsCoordinates[0]; @@ -787,6 +817,9 @@ public async Task ViewCategory(string categoryId) if (model.Category.DepartmentId != DepartmentId) return Unauthorized(); + if (model.Category.Contacts != null && model.Category.Contacts.Any()) + await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, model.Category.Contacts.ToList(), null, UserId); + return View(model); } @@ -886,6 +919,36 @@ public async Task DeleteCategory(string categoryId) return RedirectToAction("Categories", "Contacts", new { Area = "User" }); } + /// + /// ADP client-side reveal (plan 7.2): decrypted cataloged fields of one contact for a + /// caller holding a currently-valid Protected Data Grant, presented via the + /// X-Resgrid-Protected-Grant header and held in JS memory only. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Contacts_View)] + public async Task RevealContact([FromForm] string contactId) + { + if (String.IsNullOrWhiteSpace(contactId)) + return BadRequest(); + + var contact = await _contactsService.GetContactByIdAsync(contactId); + if (contact == null || contact.DepartmentId != DepartmentId) + return NotFound(); + + string grantToken = Request.Headers["X-Resgrid-Protected-Grant"]; + var resolved = await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, + new List { contact }, grantToken, UserId); + + if (resolved.IsProtected && resolved.ProtectedReason != null) + return Json(new { success = false, error = resolved.ProtectedReason }); + + var fields = Resgrid.Services.ProtectedReadService.ContactFieldAccessors + .ToDictionary(a => a.Key, a => a.Value.Get(contact)); + + return Json(new { success = true, fields }); + } + [HttpGet] [Authorize(Policy = ResgridResources.Contacts_View)] public async Task GetNotesJson(string contactId) @@ -910,7 +973,7 @@ public async Task GetNotesJson(string contactId) { var noteJson = new ContactNoteJson(); noteJson.ContactNoteId = note.ContactNoteId; - noteJson.Note = note.Note; + noteJson.Note = ProtectedDataEnvelope.SafeDisplay(note.Note); if (note.ExpiresOn.HasValue) noteJson.ExpiresOn = note.ExpiresOn.Value.FormatForDepartment(department); @@ -965,8 +1028,8 @@ public async Task GetCallsJson(string contactId) var callJson = new CallJson(); callJson.CallId = call.CallId; callJson.CallNumber = call.Number; - callJson.CallName = call.Name; - callJson.CallNature = call.NatureOfCall; + callJson.CallName = ProtectedDataEnvelope.SafeDisplay(call.Name); + callJson.CallNature = ProtectedDataEnvelope.SafeDisplay(call.NatureOfCall); callJson.LoggedOn = call.LoggedOn.FormatForDepartment(department); callJson.Priority = call.Priority; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs index fe6fad6b0..5a35e25ef 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs @@ -55,17 +55,23 @@ public class DataProtectionController : SecureBaseController "not_hipaa_compliance" }; + private const int StepUpMaxAttempts = 5; + private static readonly TimeSpan StepUpAttemptWindow = TimeSpan.FromMinutes(5); + private readonly IDepartmentDataProtectionService _dataProtectionService; private readonly IDepartmentLockService _departmentLockService; private readonly IAdpSizingService _sizingService; private readonly IProtectedDataBrokerClient _brokerClient; private readonly IDepartmentsService _departmentsService; private readonly UserManager _userManager; + private readonly IProtectedDataGrantService _grantService; + private readonly ICacheProvider _cacheProvider; public DataProtectionController(IDepartmentDataProtectionService dataProtectionService, IDepartmentLockService departmentLockService, IAdpSizingService sizingService, IProtectedDataBrokerClient brokerClient, IDepartmentsService departmentsService, - UserManager userManager) + UserManager userManager, IProtectedDataGrantService grantService, + ICacheProvider cacheProvider) { _dataProtectionService = dataProtectionService; _departmentLockService = departmentLockService; @@ -73,6 +79,8 @@ public DataProtectionController(IDepartmentDataProtectionService dataProtectionS _brokerClient = brokerClient; _departmentsService = departmentsService; _userManager = userManager; + _grantService = grantService; + _cacheProvider = cacheProvider; } public async Task Index() @@ -207,6 +215,68 @@ public async Task RevokeOffboarding(CancellationToken cancellatio return MapOutcome(outcome); } + /// + /// Verifies the caller's authenticator (TOTP) code for the ADP step-up (plan section 3) and, + /// with signing key material configured, mints a Protected Data Grant. Mirrors the v4 endpoint: + /// the web client holds the token in JS MEMORY ONLY (never a cookie, localStorage, or the URL), + /// conceals values at expiry, and prompts again on the next reveal. Rate limited per user; the + /// code is never logged. Allowed during a department lock — step-up is a read-side control. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [AllowDuringDepartmentLock] + public async Task VerifyStepUp([FromForm] string code) + { + if (string.IsNullOrWhiteSpace(code)) + return Json(new { success = false, error = "invalid_totp" }); + + var attempts = await _cacheProvider.IncrementAsync($"AdpStepUpAttempts_{UserId}", StepUpAttemptWindow); + if (attempts > StepUpMaxAttempts) + return Json(new { success = false, error = "too_many_attempts" }); + + var user = await _userManager.FindByIdAsync(UserId); + if (user == null) + return Json(new { success = false, error = "protected_access_denied" }); + + if (!await _userManager.GetTwoFactorEnabledAsync(user)) + return Json(new { success = false, error = "mfa_not_enrolled" }); + + var valid = await _userManager.VerifyTwoFactorTokenAsync(user, + _userManager.Options.Tokens.AuthenticatorTokenProvider, code.Trim()); + if (!valid) + return Json(new { success = false, error = "invalid_totp" }); + + var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(DepartmentId); + var windowMinutes = policy?.StepUpWindowMinutes > 0 + ? policy.StepUpWindowMinutes + : Config.DataProtectionConfig.StepUpWindowDefaultMinutes; + windowMinutes = Math.Min(Math.Max(1, windowMinutes), Math.Max(1, Config.DataProtectionConfig.StepUpMaximumMinutes)); + + if (!_grantService.CanIssueGrants) + return Json(new { success = false, error = "grants_not_configured" }); + + var issued = _grantService.IssueGrant(new ProtectedDataGrantIssueRequest + { + UserId = UserId, + DepartmentId = DepartmentId, + SessionId = User.FindFirst(Model.Security.SessionClaimTypes.SessionId)?.Value, + ClientApp = (int)UserSessionClientApplication.Web, + PolicyEpoch = policy?.PolicyEpoch ?? 0, + WindowMinutes = windowMinutes, + Scopes = new[] { ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write }, + MfaAtUtc = DateTime.UtcNow + }); + + return Json(new + { + success = true, + grantToken = issued.Token, + grantId = issued.GrantId, + expiresOnUtc = issued.ExpiresOnUtc.ToString("O"), + windowMinutes + }); + } + private IActionResult MapOutcome(DepartmentDataProtectionEnrollmentResult outcome) { if (outcome == DepartmentDataProtectionEnrollmentResult.Queued) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 3c57983e0..69d291e18 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -75,6 +75,7 @@ public class DispatchController : SecureBaseController private readonly IStringLocalizer _commonLocalizer; private readonly IDispatchRecommendationService _dispatchRecommendationService; private readonly IFeatureToggleService _featureToggleService; + private readonly IProtectedReadService _protectedReadService; public DispatchController(IDepartmentsService departmentsService, IUsersService usersService, ICallsService callsService, IDepartmentGroupsService departmentGroupsService, ICommunicationService communicationService, IQueueService queueService, @@ -87,7 +88,8 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService ICheckInTimerService checkInTimerService, IWeatherAlertService weatherAlertService, ICallDispatchStatusService callDispatchStatusService, IModerationService moderationService, IStringLocalizer dispatchLocalizer, IStringLocalizer commonLocalizer, - IDispatchRecommendationService dispatchRecommendationService, IFeatureToggleService featureToggleService) + IDispatchRecommendationService dispatchRecommendationService, IFeatureToggleService featureToggleService, + IProtectedReadService protectedReadService) { _departmentsService = departmentsService; _usersService = usersService; @@ -121,6 +123,7 @@ public DispatchController(IDepartmentsService departmentsService, IUsersService _commonLocalizer = commonLocalizer; _dispatchRecommendationService = dispatchRecommendationService; _featureToggleService = featureToggleService; + _protectedReadService = protectedReadService; } #endregion Private Members and Constructors @@ -748,6 +751,10 @@ public async Task UpdateCall(int callId) return Unauthorized(); model.Call = await _callsService.PopulateCallData(model.Call, true, true, true, true, true, true, true, true, true); + + // ADP: the edit form renders protected values as the REDACTED sentinel; a field posted + // back unchanged is restored to its stored envelope by the write safety net. + model.Call = (await _protectedReadService.ResolveForReadAsync(DepartmentId, model.Call, null, UserId)).Call; model.CallPriority = model.Call.Priority; model = await FillUpdateCallView(model); @@ -756,7 +763,8 @@ public async Task UpdateCall(int callId) model.ScheduleDispatchDate = model.Call.DispatchOn.Value.TimeConverter(model.Department); } - if (!String.IsNullOrEmpty(model.Call.GeoLocationData)) + if (!String.IsNullOrEmpty(model.Call.GeoLocationData) && + model.Call.GeoLocationData != ProtectedDataEnvelope.RedactionValue) { string[] loc = model.Call.GeoLocationData.Split(char.Parse(",")); model.Latitude = loc[0]; @@ -1309,6 +1317,16 @@ public async Task ViewCall(int callId) model.Protocols = await _protocolsService.GetAllProtocolsForDepartmentAsync(DepartmentId); model.ChildCalls = await _callsService.GetChildCallsForCallAsync(callId); model.Call = await _callsService.PopulateCallData(model.Call, true, true, true, true, true, true, true, true, true, true); + + // ADP (plan 7.2): server-rendered pages always render protected values as REDACTED — a + // grant lives only in the browser's memory, so the reveal is client-side (step-up modal + // then RevealCall). Resolution with no grant redacts and strips child ciphertext. + var protectedRead = await _protectedReadService.ResolveForReadAsync(DepartmentId, model.Call, null, UserId); + model.Call = protectedRead.Call; + model.IsProtectedCall = protectedRead.IsProtected; + model.ProtectedReason = protectedRead.ProtectedReason; + model.RedactedFields = protectedRead.RedactedFields?.ToList() ?? new List(); + var destinationPoi = await GetValidatedDestinationPoiAsync(model.Call.DestinationPoiId); var destinationInfo = BuildDestinationInfo(destinationPoi); model.DestinationName = destinationInfo.Name; @@ -1322,13 +1340,17 @@ public async Task ViewCall(int callId) ? model.Call.VideoFeeds.Where(f => !f.IsDeleted).ToList() : new List(); - if (!String.IsNullOrEmpty(model.Call.GeoLocationData)) + // Redacted coordinates/address never reach the map or the geocoder (a REDACTED + // placeholder is not splittable and must not leave the server as a lookup). + if (!String.IsNullOrEmpty(model.Call.GeoLocationData) && + model.Call.GeoLocationData != ProtectedDataEnvelope.RedactionValue) { string[] loc = model.Call.GeoLocationData.Split(char.Parse(",")); model.Latitude = loc[0]; model.Longitude = loc[1]; } - else if (!String.IsNullOrEmpty(model.Call.Address)) + else if (!String.IsNullOrEmpty(model.Call.Address) && + model.Call.Address != ProtectedDataEnvelope.RedactionValue) { string coordinates = await _geoLocationProvider.GetLatLonFromAddress(model.Call.Address); @@ -1342,6 +1364,36 @@ public async Task ViewCall(int callId) return View(model); } + /// + /// ADP client-side reveal (plan 7.2): returns the decrypted cataloged fields of one call for + /// a caller holding a currently-valid Protected Data Grant (issued by + /// DataProtection/VerifyStepUp, presented via the X-Resgrid-Protected-Grant header, held in + /// JS memory only). Redaction outcomes return the machine-readable reason instead of values. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Call_View)] + public async Task RevealCall([FromForm] int callId) + { + if (!await _authorizationService.CanUserViewCallAsync(UserId, callId)) + return Unauthorized(); + + var call = await _callsService.GetCallByIdAsync(callId); + if (call == null || call.DepartmentId != DepartmentId) + return NotFound(); + + string grantToken = Request.Headers["X-Resgrid-Protected-Grant"]; + var resolved = await _protectedReadService.ResolveForReadAsync(DepartmentId, call, grantToken, UserId); + + if (resolved.IsProtected && resolved.ProtectedReason != null) + return Json(new { success = false, error = resolved.ProtectedReason }); + + var fields = Resgrid.Services.ProtectedReadService.CallFieldAccessors + .ToDictionary(a => a.Key, a => a.Value.Get(resolved.Call)); + + return Json(new { success = true, fields }); + } + [HttpGet] [Authorize(Policy = ResgridResources.Call_View)] public async Task AddArchivedCall() @@ -1583,14 +1635,17 @@ public async Task CallData(int callId) model.Call = await _callsService.GetCallByIdAsync(callId); model.CallPriority = (CallPriority)model.Call.Priority; model = await FillViewCallView(model); + model.Call = (await _protectedReadService.ResolveForReadAsync(DepartmentId, model.Call, null, UserId)).Call; - if (!String.IsNullOrEmpty(model.Call.GeoLocationData)) + if (!String.IsNullOrEmpty(model.Call.GeoLocationData) && + model.Call.GeoLocationData != ProtectedDataEnvelope.RedactionValue) { string[] loc = model.Call.GeoLocationData.Split(char.Parse(",")); model.Latitude = loc[0]; model.Longitude = loc[1]; } - else if (!String.IsNullOrEmpty(model.Call.Address)) + else if (!String.IsNullOrEmpty(model.Call.Address) && + model.Call.Address != ProtectedDataEnvelope.RedactionValue) { string coordinates = await _geoLocationProvider.GetLatLonFromAddress(model.Call.Address); @@ -1683,7 +1738,7 @@ public async Task FlagCallNote(int callId, int callNoteId) FlagCallNoteView model = new FlagCallNoteView(); model.CallId = call.CallId; model.CallNoteId = note.CallNoteId; - model.CallNote = note.Note; + model.CallNote = ProtectedDataEnvelope.SafeDisplay(note.Note); var moderationRequest = await _moderationService.GetReporterRequestAsync(DepartmentId, UserId, ModerationItemType.CallNote, note.CallNoteId.ToString(CultureInfo.InvariantCulture)); var ownReport = moderationRequest?.Reports?.FirstOrDefault(); @@ -1768,7 +1823,7 @@ public async Task FlagCallImage(int callId, int callAttachmentId) var model = new FlagCallImageView(); model.CallId = call.CallId; model.CallAttachmentId = attachment.CallAttachmentId; - model.FileName = attachment.FileName; + model.FileName = ProtectedDataEnvelope.SafeDisplay(attachment.FileName); var moderationRequest = await _moderationService.GetReporterRequestAsync(DepartmentId, UserId, ModerationItemType.CallImage, attachment.CallAttachmentId.ToString(CultureInfo.InvariantCulture)); var ownReport = moderationRequest?.Reports?.FirstOrDefault(); @@ -1853,7 +1908,7 @@ public async Task FlagCallFile(int callId, int callAttachmentId) var model = new FlagCallFileView(); model.CallId = call.CallId; model.CallAttachmentId = attachment.CallAttachmentId; - model.FileName = attachment.FileName; + model.FileName = ProtectedDataEnvelope.SafeDisplay(attachment.FileName); model.FileType = attachment.CallAttachmentType; model.IsFlagged = attachment.IsFlagged; model.FlagNote = attachment.FlaggedReason; @@ -1988,7 +2043,7 @@ public async Task GetCallNotes(int callId) note.IsFlagged = flaggedCallNoteIds.Contains(callNote.CallNoteId.ToString(CultureInfo.InvariantCulture)); note.Name = name.Name; note.Timestamp = callNote.Timestamp.TimeConverter(call.Department).FormatForDepartment(call.Department); - note.Note = callNote.Note; + note.Note = ProtectedDataEnvelope.SafeDisplay(callNote.Note); note.UserId = callNote.UserId; if (callNote.Latitude.HasValue && callNote.Longitude.HasValue) @@ -2158,6 +2213,7 @@ public async Task CallExport(int callId) model.Groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); model.Units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId); model.Call = await _callsService.PopulateCallData(model.Call, true, true, true, true, true, true, true, true, true); + model.Call = (await _protectedReadService.ResolveForReadAsync(DepartmentId, model.Call, null, UserId)).Call; var callDestination = await GetValidatedDestinationPoiAsync(model.Call.DestinationPoiId); var callDestinationInfo = BuildDestinationInfo(callDestination); model.DestinationName = callDestinationInfo.Name; @@ -2209,6 +2265,7 @@ public async Task CallExportEx(string query) var model = new CallExportView(); model.Call = await _callsService.PopulateCallData(call, true, true, true, true, true, true, true, true, true); + model.Call = (await _protectedReadService.ResolveForReadAsync(call.DepartmentId, model.Call, null, UserId)).Call; var destinationPoi = await GetValidatedDestinationPoiAsync(model.Call.DestinationPoiId); var destinationInfo = BuildDestinationInfo(destinationPoi); model.DestinationName = destinationInfo.Name; @@ -2245,6 +2302,7 @@ public async Task CallExportEx(string query) var model = new CallExportView(); model.Call = await _callsService.PopulateCallData(call, true, true, true, true, true, true, true, true, true); + model.Call = (await _protectedReadService.ResolveForReadAsync(call.DepartmentId, model.Call, null, UserId)).Call; var destinationPoi = await GetValidatedDestinationPoiAsync(model.Call.DestinationPoiId); var destinationInfo = BuildDestinationInfo(destinationPoi); model.DestinationName = destinationInfo.Name; @@ -2289,9 +2347,16 @@ public async Task CallExportEx(string query) string endLat = ""; string endLon = ""; - var callCocationParts = call.GeoLocationData.Split(char.Parse(",")); - endLat = callCocationParts[0]; - endLon = callCocationParts[1]; + if (!String.IsNullOrWhiteSpace(call.GeoLocationData) && + !ProtectedDataEnvelope.HasEnvelopePrefix(call.GeoLocationData)) + { + var callCocationParts = call.GeoLocationData.Split(char.Parse(",")); + if (callCocationParts.Length == 2) + { + endLat = callCocationParts[0]; + endLon = callCocationParts[1]; + } + } model.StartLat = startLat; model.StartLon = startLon; @@ -2360,7 +2425,11 @@ public async Task GetCallDispatchAudio(int callId) if (call.Attachments != null && call.Attachments.Count > 0) { - return File(call.Attachments.First().Data, "audio/mpeg"); + var audio = call.Attachments.First().Data; + + // An enveloped payload (protected department) is ciphertext, not audio. + if (audio != null && !Resgrid.Services.ProtectedReadService.IsBinaryEnveloped(audio)) + return File(audio, "audio/mpeg"); } return RedirectToAction("Dashboard"); @@ -2390,7 +2459,7 @@ public async Task GetActiveCallsForGrid() jsonCall.CallId = call.CallId; jsonCall.DispatchTime = call.LoggedOn; jsonCall.Priority = await DispatchDisplayHelper.GetLocalizedCallPriorityAsync(DepartmentId, call.Priority, _dispatchLocalizer); - jsonCall.Name = call.Name; + jsonCall.Name = ProtectedDataEnvelope.SafeDisplay(call.Name); calls.Add(jsonCall); } @@ -2412,7 +2481,7 @@ public async Task GetAllCallsForGrid() jsonCall.CallId = call.CallId; jsonCall.DispatchTime = call.LoggedOn; jsonCall.Priority = await DispatchDisplayHelper.GetLocalizedCallPriorityAsync(DepartmentId, call.Priority, _dispatchLocalizer); - jsonCall.Name = call.Name; + jsonCall.Name = ProtectedDataEnvelope.SafeDisplay(call.Name); jsonCall.State = DispatchDisplayHelper.GetLocalizedCallState(call.State, _dispatchLocalizer, _commonLocalizer); calls.Add(jsonCall); @@ -2447,10 +2516,10 @@ public async Task GetCallById(int callId) call.DispatchTime = savedCall.LoggedOn.TimeConverter(savedCall.Department); call.Priority = await DispatchDisplayHelper.GetLocalizedCallPriorityAsync(savedCall.DepartmentId, savedCall.Priority, _dispatchLocalizer); call.PriorityEnum = (CallPriority)savedCall.Priority; - call.Name = savedCall.Name; + call.Name = ProtectedDataEnvelope.SafeDisplay(savedCall.Name); call.State = DispatchDisplayHelper.GetLocalizedCallState(savedCall.State, _dispatchLocalizer, _commonLocalizer); - call.Nature = savedCall.NatureOfCall; - call.Address = savedCall.Address; + call.Nature = ProtectedDataEnvelope.SafeDisplay(savedCall.NatureOfCall); + call.Address = ProtectedDataEnvelope.SafeDisplay(savedCall.Address); return Json(call); } @@ -2556,7 +2625,8 @@ public async Task GetMapDataForCall(int callId) model.CenterLat = coordiantes.Latitude.Value; model.CenterLon = coordiantes.Longitude.Value; - if (!String.IsNullOrWhiteSpace(call.GeoLocationData)) + if (!String.IsNullOrWhiteSpace(call.GeoLocationData) && + !ProtectedDataEnvelope.HasEnvelopePrefix(call.GeoLocationData)) { string[] coordinates = call.GeoLocationData.Split(char.Parse(",")); @@ -2574,7 +2644,7 @@ public async Task GetMapDataForCall(int callId) var markerInfo = new MapMakerInfo(); markerInfo.Latitude = model.CenterLat; markerInfo.Longitude = model.CenterLon; - markerInfo.Title = call.Name; + markerInfo.Title = ProtectedDataEnvelope.SafeDisplay(call.Name); model.MapMakerInfos.Add(markerInfo); } @@ -2636,7 +2706,7 @@ public async Task CallsTypesInRange(string startDate, string endD { string key = _dispatchLocalizer["NoType"].Value; if (!String.IsNullOrWhiteSpace(grouppedCall.Key)) - key = grouppedCall.Key; + key = ProtectedDataEnvelope.SafeDisplay(grouppedCall.Key); callTypes.Add(new CallTypesJson() { Count = grouppedCall.ToList().Count, Type = key }); } @@ -2689,7 +2759,7 @@ public async Task GetActiveCallsList() var callJson = new CallListJson(); callJson.CallId = call.CallId; callJson.Number = call.Number; - callJson.Name = call.Name; + callJson.Name = ProtectedDataEnvelope.SafeDisplay(call.Name); callJson.State = DispatchDisplayHelper.GetLocalizedCallState(call.State, _dispatchLocalizer, _commonLocalizer); callJson.StateColor = _callsService.CallStateToColor((CallStates)call.State); callJson.Timestamp = call.LoggedOn.TimeConverterToString(department); @@ -2730,7 +2800,7 @@ public async Task GetArchivedCallsList(string year) var callJson = new CallListJson(); callJson.CallId = call.CallId; callJson.Number = call.Number; - callJson.Name = call.Name; + callJson.Name = ProtectedDataEnvelope.SafeDisplay(call.Name); callJson.State = DispatchDisplayHelper.GetLocalizedCallState(call.State, _dispatchLocalizer, _commonLocalizer); callJson.StateColor = _callsService.CallStateToColor((CallStates)call.State); callJson.Timestamp = call.LoggedOn.TimeConverterToString(department); @@ -2764,7 +2834,7 @@ public async Task GetScheduledCallsList() var callJson = new CallListJson(); callJson.CallId = call.CallId; callJson.Number = call.Number; - callJson.Name = call.Name; + callJson.Name = ProtectedDataEnvelope.SafeDisplay(call.Name); callJson.State = DispatchDisplayHelper.GetLocalizedCallState(call.State, _dispatchLocalizer, _commonLocalizer); callJson.StateColor = _callsService.CallStateToColor((CallStates)call.State); callJson.Timestamp = call.LoggedOn.TimeConverterToString(department); @@ -2840,6 +2910,12 @@ public async Task GetCallFile(int callAttachmentId) if (attachment.Call.DepartmentId != DepartmentId) return Unauthorized(); + // ADP: an enveloped payload/name is ciphertext — the MVC surface has no per-download + // grant flow yet, so a protected file is simply not served here. + if ((attachment.Data != null && Resgrid.Services.ProtectedReadService.IsBinaryEnveloped(attachment.Data)) || + ProtectedDataEnvelope.HasEnvelopePrefix(attachment.FileName)) + return NotFound(); + return new FileContentResult(attachment.Data, FileHelper.GetContentTypeByExtension(Path.GetExtension(attachment.FileName))) { FileDownloadName = attachment.FileName @@ -2885,6 +2961,10 @@ public async Task GetCallImage(int callId, int attachmentId, stri if (!isAuthorized) return Unauthorized(); + // ADP: never serve an enveloped (ciphertext) image payload. + if (Resgrid.Services.ProtectedReadService.IsBinaryEnveloped(callAttachment.Data)) + return NotFound(); + return File(callAttachment.Data, "image/jpeg"); } diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs index c23fb5392..8b59a5e6b 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/ViewCallView.cs @@ -32,6 +32,11 @@ public class ViewCallView: BaseUserModel public string DestinationAddress { get; set; } public string DestinationTypeName { get; set; } + /// ADP: true when this call carries protected fields rendered as REDACTED (plan 7.2). + public bool IsProtectedCall { get; set; } + public string ProtectedReason { get; set; } + public List RedactedFields { get; set; } = new List(); + public string IsMapTabActive() { if (!String.IsNullOrEmpty(Call.Address) || !String.IsNullOrEmpty(Call.GeoLocationData)) diff --git a/Web/Resgrid.Web/Areas/User/Models/Contacts/ViewContactView.cs b/Web/Resgrid.Web/Areas/User/Models/Contacts/ViewContactView.cs index b193f4e5b..6898bacdd 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Contacts/ViewContactView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Contacts/ViewContactView.cs @@ -14,5 +14,8 @@ public class ViewContactView public List NoteTypes { get; set; } public List RouteStops { get; set; } public List RoutePlans { get; set; } + + /// ADP: true when this contact carries protected fields rendered as REDACTED (plan 7.2). + public bool IsProtectedContact { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Report.cshtml b/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Report.cshtml index 7c5ed615d..5c5a1a516 100644 --- a/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Report.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/CommunicationTest/Report.cshtml @@ -25,6 +25,15 @@ int totalVerified = verifiableResults.Count(r => r.VerificationStatus == (int)ContactVerificationStatus.Verified); int totalUnverified = verifiableResults.Count(r => r.VerificationStatus == (int)ContactVerificationStatus.Pending); int totalGrandfathered = verifiableResults.Count(r => r.VerificationStatus == (int)ContactVerificationStatus.Grandfathered); + + // Counted over users, not rows: one muted member produces a suppressed row per tested channel, + // and "12 suppressed" meaning three people would be a worse number than no number. + int suppressedUserCount = userResults.Count(g => g.Value.Any(r => r.Suppressed)); + + // Pulled out of the markup: a localizer indexer inside an HTML attribute ends the attribute on + // its own quotes. + var suppressedHelpText = localizer["SuppressedHelp"].Value; + var suppressedNoticeText = string.Format(localizer["SuppressedNotice"].Value, suppressedUserCount); }
@@ -54,6 +63,16 @@
} + @if (suppressedUserCount > 0) + { +
+
+
+ @suppressedNoticeText +
+
+
+ }
@@ -157,6 +176,8 @@ @localizer["User"] + @localizer["StaffingLevel"] + @localizer["Suppressed"] @localizer["SmsEnabled"] @localizer["SMS"] @localizer["SmsContact"] @@ -194,6 +215,9 @@ string GetStatusClass(CommunicationTestResult r) { if (r == null) return ""; + // Muted by the department, not a fault and not a non-response: + // its own colour so it cannot be read as either. + if (r.Suppressed) return "background-color:#d9edf7;"; if (!r.SendAttempted) return "background-color:#e0e0e0;"; if (r.Responded) return "background-color:#dff0d8;"; // A send that failed is a fault to chase; a delivered message with no @@ -208,6 +232,12 @@ if (!r.SendAttempted) { + // The department muted this member's staffing level. Said first, + // because otherwise this reads as the member having switched the + // channel off themselves. + if (r.Suppressed) + return localizer["NotSentSuppressed"].Value; + // Say WHY nothing was sent, so the verification column and this one // read as one story instead of two unrelated facts. if (r.HasVerifiableContactMethod() && r.VerificationStatus == (int)ContactVerificationStatus.Pending) @@ -262,16 +292,33 @@ return enabled ? "color:green;" : "color:red;"; } - // Mirror the predicate CommunicationTestService uses to decide whether to - // attempt a channel. Reading a single flag here would show "Enabled: No" - // next to a message the run actually sent, which reads as a broken report. - var smsEnabled = profile != null && (profile.SendSms || profile.SendMessageSms || profile.SendNotificationSms); - var emailEnabled = profile != null && (profile.SendEmail || profile.SendMessageEmail || profile.SendNotificationEmail); - var voiceEnabled = profile != null && profile.VoiceForCall; - var pushEnabled = profile != null && profile.SendNotificationPush; + // The run recorded each member's election when it was built, so the report + // describes the run rather than today's profile. The fallbacks mirror the + // predicates CommunicationTestService uses, and only apply to runs built + // before the election was recorded. + var smsEnabled = smsResult.GetChannelElection(profile != null && (profile.SendSms || profile.SendMessageSms || profile.SendNotificationSms)); + var emailEnabled = emailResult.GetChannelElection(profile != null && (profile.SendEmail || profile.SendMessageEmail || profile.SendNotificationEmail)); + var voiceEnabled = voiceResult.GetChannelElection(profile != null && profile.VoiceForCall); + var pushEnabled = pushResult.GetChannelElection(profile != null && profile.SendNotificationPush); + + // Every row for a member carries the same staffing snapshot, so any one of + // them answers for the member. + var staffingText = results.FirstOrDefault().GetStaffingLevelDisplayText(); + var userSuppressed = results.Any(r => r.Suppressed); @userName + @staffingText + + @if (userSuppressed) + { + @GetEnabledText(true) + } + else + { + @GetEnabledText(false) + } + @GetEnabledText(smsEnabled) @GetStatusText(smsResult) @(smsResult?.ContactValue ?? "-") diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml index 08ba46d24..ed5720353 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml @@ -6,9 +6,18 @@ }
+ @if (Model.IsProtectedContact) + { +
+ + Protected contact — encrypted at rest for this department. Authorized users and approved channels may still disclose it. + + +
+ }
-
@Model.Contact.GetName()
+
@Model.Contact.GetName()
@@ -60,7 +69,7 @@
@localizer["Name"]:
-
@Model.Contact.GetName()
+
@Model.Contact.GetName()
@@ -72,19 +81,19 @@
@localizer["Email"]:
-
@Model.Contact.Email
+
@Model.Contact.Email
@localizer["CellPhoneLabel"]:
-
@Model.Contact.CellPhoneNumber
+
@Model.Contact.CellPhoneNumber
@localizer["HomePhoneLabel"]:
-
@Model.Contact.HomePhoneNumber
+
@Model.Contact.HomePhoneNumber
@@ -160,19 +169,19 @@
@localizer["LocationGpsLabel"]:
-
@Model.Contact.LocationGpsCoordinates
+
@Model.Contact.LocationGpsCoordinates
@localizer["EntranceGpsLabel"]:
-
@Model.Contact.EntranceGpsCoordinates
+
@Model.Contact.EntranceGpsCoordinates
@localizer["ExitGpsLabel"]:
-
@Model.Contact.ExitGpsCoordinates
+
@Model.Contact.ExitGpsCoordinates
@@ -314,7 +323,7 @@
- @Html.Raw(Model.Contact.Description) + @Html.Raw(Model.Contact.Description)
@@ -332,7 +341,7 @@
- @Html.Raw(Model.Contact.OtherInfo) + @Html.Raw(Model.Contact.OtherInfo)
@@ -598,8 +607,49 @@
+@if (Model.IsProtectedContact) +{ + +} + @section Scripts { + @if (Model.IsProtectedContact) + { +
@Html.AntiForgeryToken()
+ + + } + + } @if (Model.Call.ActiveRunCardId.HasValue) {