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