diff --git a/Core/Resgrid.Config/DataProtectionConfig.cs b/Core/Resgrid.Config/DataProtectionConfig.cs index 5b564d137..9a269c05d 100644 --- a/Core/Resgrid.Config/DataProtectionConfig.cs +++ b/Core/Resgrid.Config/DataProtectionConfig.cs @@ -13,6 +13,28 @@ public static class DataProtectionConfig /// Base URL of the Protected Data Broker service (empty = no broker deployed). public static string BrokerBaseUrl = ""; + /// + /// Grace in days after the paid-through date before an AUTOMATIC-billing lapse can schedule + /// offboarding. Matches PlanAddon.GetEndDateFromNow()'s yearly + 14 days, which is the grace + /// the rest of the platform already gives a failed renewal. + /// + public static int AddonAutomaticBillingGraceDays = 14; + + /// + /// Grace in days for an INVOICED department. Sized for the worst common terms rather than the + /// average: NET45 plus a fortnight of internal approval plus a fortnight of cheque handling + /// still lands inside 75 days. Decrypting a customer's data because their finance team is + /// slow is not a recoverable mistake, and the cost of being generous here is a few weeks of + /// protection nobody has paid for yet. + /// + public static int AddonInvoicedBillingGraceDays = 75; + + /// + /// Ceiling on any per-department grace override. Support can extend a genuine slow payer; + /// nobody can accidentally grant a permanent free ride by typing an extra digit. + /// + public static int AddonMaxGraceDays = 180; + /// Audience the application tier expects on broker mTLS/workload credentials. public static string BrokerAudience = "resgrid-protected-broker"; diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.ar.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.ar.resx index 83d62e92f..1a1eaac1c 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.ar.resx @@ -1,4 +1,4 @@ - + @@ -403,4 +403,136 @@ إزالة جهة اتصال الطوارئ هذه؟ + + إضافة الحماية المتقدمة للبيانات + + + الاشتراك + + + إضافة الحماية المتقدمة للبيانات + + + إدارة الحماية المتقدمة للبيانات + + + سنويًا + + + تقوم الحماية المتقدمة للبيانات بتشفير الحقول الحساسة في إدارتكم بمفتاح مخصص لها وحدها. بعد التفعيل، تظل القيم المحمية مخفية إلى أن يؤكد أحد الأعضاء هويته بعامل ثانٍ، وتبقى مشفرة في عمليات التصدير والإشعارات والتقارير. + + + تشفير الحقول الحساسة على مستوى الإدارة + + + تأكيد بعامل ثانٍ قبل إظهار القيم المحمية + + + التحكم فيما يخرج عبر البريد الإلكتروني والرسائل القصيرة والإشعارات الفورية + + + سجل تدقيق لكل قراءة محمية لا يتضمن أي قيم بذاته + + + شراء الإضافة + + + إدارة الإضافة + + + يستطيع العضو المسؤول عن الإدارة وحده شراء هذه الإضافة أو إلغاءها. + + + تتطلب الحماية المتقدمة للبيانات خطة مدفوعة. + + + شراء الإضافة وحده لا يشفّر أي شيء. يجري تفعيل إدارتكم لاحقًا من صفحة إعدادات حماية البيانات في الوقت الذي تختارونه. + + + الحالة + + + نشطة + + + ملغاة + + + تتجدد في + + + مدفوعة حتى + + + حالة الحماية + + + لا تملك إدارتكم هذه الإضافة. + + + لم نستلم دفعتكم بعد. تستمر الحماية حتى {0} — والدفع قبل ذلك التاريخ يُبقي كل شيء مشفرًا دون فك تشفير أي بيانات. + + + تنتهي الحماية في {0}. حتى ذلك الحين يبقى كل شيء مشفرًا، ولا يزال بإمكان العضو المسؤول التراجع عن ذلك. + + + إلغاء الإضافة + + + عند الإلغاء تستمر الحماية حتى نهاية المدة المدفوعة. بعد ذلك تقوم عملية ترحيل ليلية بفك تشفير بيانات إدارتكم وإعادتها إلى التخزين المعتاد. + + + إلغاء الحماية المتقدمة للبيانات لهذه الإدارة؟ + + + فتح إعدادات حماية البيانات + + + طلب التحقق + + + قبل إظهار أي قيمة محمية، يطلب Resgrid من العضو رمزًا من تطبيق المصادقة. ويمكنكم إيقاف هذا الطلب لتطبيقات بعينها. + + + اتركوه مفعّلًا حيثما أمكن. إيقافه يعني أن أي شخص سجّل الدخول بالفعل على ذلك الجهاز يستطيع كشف البيانات المحمية دون إثبات هويته — وهو أمر مقبول على وحدة الإرسال أثناء حادث، وأقل قبولًا بكثير على هاتف قد يُفقد. + + + موقع Resgrid + + + وحدة الإرسال + + + تطبيق Responder + + + تطبيق Unit + + + تطبيق قيادة الحادث + + + تكاملات واجهة البرمجة + + + طلب رمز + + + بدون طلب + + + حفظ إعدادات التحقق + + + تغيير طلب التحقق لهذه الإدارة؟ + + + تعذّر حفظ إعدادات التحقق. + + + يستطيع العضو المسؤول عن الإدارة وحده تغيير ذلك. + + + تُسجَّل كل تغييرات مع اسم من أجراها ووقتها. + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.de.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.de.resx index d8f7a5efa..4c6be57d3 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.de.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.de.resx @@ -1,4 +1,4 @@ - + @@ -403,4 +403,136 @@ Diesen Notfallkontakt entfernen? + + Add-on „Erweiterter Datenschutz“ + + + Abonnement + + + Erweiterten Datenschutz hinzufügen + + + Erweiterten Datenschutz verwalten + + + pro Jahr + + + Der erweiterte Datenschutz verschlüsselt die sensiblen Felder Ihrer Abteilung mit einem Schlüssel, der ausschließlich für Ihre Abteilung vorgehalten wird. Nach der Registrierung bleiben geschützte Werte verborgen, bis ein Mitglied sich mit einem zweiten Faktor bestätigt, und sie bleiben in Exporten, Benachrichtigungen und Berichten verschlüsselt. + + + Verschlüsselung sensibler Felder je Abteilung + + + Bestätigung mit einem zweiten Faktor, bevor geschützte Werte angezeigt werden + + + Kontrolle darüber, was per E-Mail, SMS und Push-Benachrichtigung das System verlässt + + + Ein Prüfpfad über jeden geschützten Zugriff, der selbst keine Werte enthält + + + Add-on kaufen + + + Add-on verwalten + + + Nur das verwaltende Mitglied der Abteilung kann dieses Add-on kaufen oder kündigen. + + + Der erweiterte Datenschutz erfordert einen kostenpflichtigen Tarif. + + + Der Kauf des Add-ons verschlüsselt für sich genommen noch nichts. Ihre Abteilung wird danach auf der Seite „Datenschutz“ zu einem von Ihnen gewählten Zeitpunkt registriert. + + + Status + + + Aktiv + + + Gekündigt + + + Verlängert sich am + + + Bezahlt bis + + + Schutzstatus + + + Ihre Abteilung verfügt nicht über dieses Add-on. + + + Ihre Zahlung ist noch nicht bei uns eingegangen. Der Schutz läuft bis zum {0} weiter — wenn Sie vorher zahlen, bleibt alles verschlüsselt und nichts wird entschlüsselt. + + + Der Schutz endet am {0}. Bis dahin bleibt alles verschlüsselt, und das verwaltende Mitglied kann dies noch rückgängig machen. + + + Add-on kündigen + + + Bei einer Kündigung läuft der Schutz bis zum Ende des von Ihnen bezahlten Zeitraums weiter. Danach entschlüsselt eine nächtliche Migration die Daten Ihrer Abteilung zurück in die normale Speicherung. + + + Erweiterten Datenschutz für diese Abteilung kündigen? + + + Datenschutz-Einstellungen öffnen + + + Bestätigungsabfrage + + + Bevor ein geschützter Wert angezeigt wird, fragt Resgrid das Mitglied nach einem Code aus seiner Authenticator-App. Sie können diese Abfrage für einzelne Anwendungen abschalten. + + + Lassen Sie sie eingeschaltet, wo immer es geht. Abschalten bedeutet, dass jede bereits angemeldete Person an diesem Gerät geschützte Daten aufdecken kann, ohne ihre Identität nachzuweisen — auf einer Leitstellenkonsole im Einsatz vertretbar, auf einem Telefon, das verloren gehen kann, deutlich weniger. + + + Resgrid-Website + + + Leitstellenkonsole + + + Responder-App + + + Unit-App + + + Einsatzleitungs-App + + + API-Integrationen + + + Code abfragen + + + Keine Abfrage + + + Bestätigungseinstellungen speichern + + + Die Bestätigungsabfrage für diese Abteilung ändern? + + + Die Bestätigungseinstellungen konnten nicht gespeichert werden. + + + Nur das verwaltende Mitglied der Abteilung kann dies ändern. + + + Jede Änderung wird mit Urheber und Zeitpunkt protokolliert. + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.el.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.el.resx index 8b556476d..96a95f372 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.el.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.el.resx @@ -1,4 +1,4 @@ - + @@ -403,4 +403,136 @@ Κατάργηση αυτής της επαφής έκτακτης ανάγκης; + + Πρόσθετο Προηγμένης Προστασίας Δεδομένων + + + Συνδρομή + + + Προσθήκη Προηγμένης Προστασίας Δεδομένων + + + Διαχείριση Προηγμένης Προστασίας Δεδομένων + + + ανά έτος + + + Η Προηγμένη Προστασία Δεδομένων κρυπτογραφεί τα ευαίσθητα πεδία της υπηρεσίας σας με κλειδί που τηρείται αποκλειστικά για αυτήν. Μετά την ενεργοποίηση, οι προστατευμένες τιμές παραμένουν κρυφές έως ότου ένα μέλος επιβεβαιωθεί με δεύτερο παράγοντα, και παραμένουν κρυπτογραφημένες σε εξαγωγές, ειδοποιήσεις και αναφορές. + + + Κρυπτογράφηση ευαίσθητων πεδίων ανά υπηρεσία + + + Επιβεβαίωση με δεύτερο παράγοντα πριν εμφανιστούν οι προστατευμένες τιμές + + + Έλεγχος όσων εξέρχονται μέσω email, SMS και ειδοποιήσεων push + + + Ιστορικό ελέγχου κάθε προστατευμένης ανάγνωσης, το οποίο δεν περιέχει τιμές + + + Αγορά προσθέτου + + + Διαχείριση προσθέτου + + + Μόνο το διαχειριστικό μέλος της υπηρεσίας μπορεί να αγοράσει ή να ακυρώσει αυτό το πρόσθετο. + + + Η Προηγμένη Προστασία Δεδομένων απαιτεί πληρωμένο πρόγραμμα. + + + Η αγορά του προσθέτου δεν κρυπτογραφεί από μόνη της τίποτα. Η υπηρεσία σας ενεργοποιείται στη συνέχεια, από τη σελίδα Προστασίας Δεδομένων, όποτε το επιλέξετε. + + + Κατάσταση + + + Ενεργό + + + Ακυρωμένο + + + Ανανεώνεται στις + + + Πληρωμένο έως + + + Κατάσταση προστασίας + + + Η υπηρεσία σας δεν διαθέτει αυτό το πρόσθετο. + + + Δεν έχουμε λάβει ακόμη την πληρωμή σας. Η προστασία συνεχίζεται έως τις {0} — αν πληρώσετε πριν από τότε, όλα παραμένουν κρυπτογραφημένα και τίποτα δεν αποκρυπτογραφείται. + + + Η προστασία λήγει στις {0}. Έως τότε όλα παραμένουν κρυπτογραφημένα και το διαχειριστικό μέλος μπορεί ακόμη να το αναιρέσει. + + + Ακύρωση προσθέτου + + + Με την ακύρωση, η προστασία συνεχίζεται έως το τέλος της περιόδου που έχετε πληρώσει. Στη συνέχεια, μια νυχτερινή μεταφορά αποκρυπτογραφεί τα δεδομένα της υπηρεσίας σας και τα επαναφέρει στην κανονική αποθήκευση. + + + Ακύρωση της Προηγμένης Προστασίας Δεδομένων για αυτήν την υπηρεσία; + + + Άνοιγμα ρυθμίσεων Προστασίας Δεδομένων + + + Αίτημα επαλήθευσης + + + Πριν εμφανιστεί μια προστατευμένη τιμή, το Resgrid ζητά από το μέλος έναν κωδικό από την εφαρμογή ελέγχου ταυτότητας. Μπορείτε να απενεργοποιήσετε αυτό το αίτημα για συγκεκριμένες εφαρμογές. + + + Αφήστε το ενεργό όπου μπορείτε. Η απενεργοποίηση σημαίνει ότι όποιος έχει ήδη συνδεθεί σε εκείνη τη συσκευή μπορεί να αποκαλύψει προστατευμένα δεδομένα χωρίς να αποδείξει ποιος είναι — ανεκτό σε κονσόλα διαχείρισης κατά τη διάρκεια συμβάντος, πολύ λιγότερο σε τηλέφωνο που μπορεί να χαθεί. + + + Ιστότοπος Resgrid + + + Κονσόλα διαχείρισης κλήσεων + + + Εφαρμογή Responder + + + Εφαρμογή Unit + + + Εφαρμογή Διοίκησης Συμβάντος + + + Ενσωματώσεις API + + + Να ζητείται κωδικός + + + Χωρίς αίτημα + + + Αποθήκευση ρυθμίσεων επαλήθευσης + + + Αλλαγή του αιτήματος επαλήθευσης για αυτήν την υπηρεσία; + + + Δεν ήταν δυνατή η αποθήκευση των ρυθμίσεων επαλήθευσης. + + + Μόνο το διαχειριστικό μέλος της υπηρεσίας μπορεί να το αλλάξει. + + + Κάθε αλλαγή καταγράφεται με το ποιος την έκανε και πότε. + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.en.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.en.resx index 81bb1dc53..3665bb14f 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.en.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.en.resx @@ -1,4 +1,4 @@ - + @@ -403,4 +403,136 @@ Remove this emergency contact? + + Advanced Data Protection Addon + + + Subscription + + + Add Advanced Data Protection + + + Manage Advanced Data Protection + + + per year + + + Advanced Data Protection encrypts your department's sensitive fields with a key held only for your department. Once enrolled, protected values stay hidden until a member confirms who they are with a second factor, and they remain encrypted in exports, notifications and reports. + + + Per-department encryption of sensitive fields + + + Second-factor confirmation before protected values are shown + + + Control over what leaves in email, SMS and push notifications + + + An audit trail of every protected read that holds no values itself + + + Purchase Addon + + + Manage Addon + + + Only the department's managing member can purchase or cancel this addon. + + + Advanced Data Protection requires a paid plan. + + + Purchasing the addon does not encrypt anything by itself. Your department is enrolled afterwards, from the Data Protection settings page, at a time you choose. + + + Status + + + Active + + + Cancelled + + + Renews on + + + Paid through + + + Protection state + + + Your department does not have this addon. + + + We have not received your payment yet. Protection continues until {0} — paying before then keeps everything encrypted and nothing is decrypted. + + + Protection ends on {0}. Until then everything stays encrypted, and the managing member can still reverse this. + + + Cancel Addon + + + Cancelling keeps protection running until the end of the period you have paid for. After that an overnight migration decrypts your department's data back to normal storage. + + + Cancel Advanced Data Protection for this department? + + + Open Data Protection settings + + + Verification prompt + + + Before a protected value is shown, Resgrid asks the member for a code from their authenticator app. You can switch that prompt off for individual applications. + + + Leave this on wherever you can. Switching it off means anyone already signed in on that device can reveal protected data without proving who they are — worth accepting on a dispatch console during an incident, much less so on a phone that can be lost. + + + Resgrid web site + + + Dispatch console + + + Responder app + + + Unit app + + + Incident Command app + + + API integrations + + + Prompt for a code + + + No prompt + + + Save verification settings + + + Change the verification prompt for this department? + + + The verification settings could not be saved. + + + Only the department's managing member can change this. + + + Every change is recorded with who made it and when. + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.es.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.es.resx index ae446754d..6d3a1dd60 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.es.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.es.resx @@ -1,4 +1,4 @@ - + @@ -403,4 +403,136 @@ ¿Eliminar este contacto de emergencia? + + Complemento de Protección de Datos Avanzada + + + Suscripción + + + Añadir Protección de Datos Avanzada + + + Gestionar la Protección de Datos Avanzada + + + al año + + + La Protección de Datos Avanzada cifra los campos sensibles de su departamento con una clave reservada únicamente a su departamento. Una vez activada, los valores protegidos permanecen ocultos hasta que un miembro se confirma con un segundo factor, y siguen cifrados en exportaciones, notificaciones e informes. + + + Cifrado de campos sensibles por departamento + + + Confirmación con un segundo factor antes de mostrar los valores protegidos + + + Control sobre lo que sale por correo electrónico, SMS y notificaciones push + + + Un registro de auditoría de cada lectura protegida que no contiene valores + + + Comprar complemento + + + Gestionar complemento + + + Solo el miembro administrador del departamento puede comprar o cancelar este complemento. + + + La Protección de Datos Avanzada requiere un plan de pago. + + + Comprar el complemento no cifra nada por sí solo. Su departamento se activa después, desde la página de Protección de Datos, en el momento que usted elija. + + + Estado + + + Activo + + + Cancelado + + + Se renueva el + + + Pagado hasta + + + Estado de la protección + + + Su departamento no dispone de este complemento. + + + Todavía no hemos recibido su pago. La protección continúa hasta el {0}: si paga antes de esa fecha, todo sigue cifrado y no se descifra nada. + + + La protección termina el {0}. Hasta entonces todo sigue cifrado y el miembro administrador aún puede revertirlo. + + + Cancelar complemento + + + Al cancelar, la protección sigue activa hasta el final del periodo que ha pagado. Después, una migración nocturna descifra los datos de su departamento y los devuelve al almacenamiento normal. + + + ¿Cancelar la Protección de Datos Avanzada de este departamento? + + + Abrir los ajustes de Protección de Datos + + + Solicitud de verificación + + + Antes de mostrar un valor protegido, Resgrid pide al miembro un código de su aplicación de autenticación. Puede desactivar esa solicitud para aplicaciones concretas. + + + Déjela activada siempre que pueda. Desactivarla significa que cualquiera que ya haya iniciado sesión en ese dispositivo puede revelar datos protegidos sin demostrar quién es: asumible en una consola de despacho durante un incidente, mucho menos en un teléfono que puede perderse. + + + Sitio web de Resgrid + + + Consola de despacho + + + Aplicación Responder + + + Aplicación Unit + + + Aplicación de Mando de Incidentes + + + Integraciones de API + + + Pedir un código + + + Sin solicitud + + + Guardar ajustes de verificación + + + ¿Cambiar la solicitud de verificación de este departamento? + + + No se han podido guardar los ajustes de verificación. + + + Solo el miembro administrador del departamento puede cambiar esto. + + + Cada cambio se registra con quién lo hizo y cuándo. + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.fr.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.fr.resx index c865080c3..ad6a1e17e 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.fr.resx @@ -1,4 +1,4 @@ - + @@ -403,4 +403,136 @@ Supprimer ce contact d'urgence ? + + Module Protection avancée des données + + + Abonnement + + + Ajouter la Protection avancée des données + + + Gérer la Protection avancée des données + + + par an + + + La Protection avancée des données chiffre les champs sensibles de votre service avec une clé réservée à votre seul service. Une fois activée, les valeurs protégées restent masquées jusqu'à ce qu'un membre se confirme avec un second facteur, et elles restent chiffrées dans les exports, les notifications et les rapports. + + + Chiffrement des champs sensibles par service + + + Confirmation par second facteur avant l'affichage des valeurs protégées + + + Maîtrise de ce qui sort par e-mail, SMS et notification push + + + Une piste d'audit de chaque lecture protégée, qui ne contient elle-même aucune valeur + + + Acheter le module + + + Gérer le module + + + Seul le membre gestionnaire du service peut acheter ou résilier ce module. + + + La Protection avancée des données nécessite une formule payante. + + + L'achat du module ne chiffre rien en soi. Votre service est activé ensuite, depuis la page Protection des données, au moment que vous choisissez. + + + Statut + + + Actif + + + Résilié + + + Se renouvelle le + + + Payé jusqu'au + + + État de la protection + + + Votre service ne dispose pas de ce module. + + + Nous n'avons pas encore reçu votre paiement. La protection se poursuit jusqu'au {0} : si vous payez avant cette date, tout reste chiffré et rien n'est déchiffré. + + + La protection prend fin le {0}. D'ici là tout reste chiffré, et le membre gestionnaire peut encore annuler cette décision. + + + Résilier le module + + + En cas de résiliation, la protection reste active jusqu'à la fin de la période que vous avez payée. Ensuite, une migration nocturne déchiffre les données de votre service et les remet en stockage normal. + + + Résilier la Protection avancée des données pour ce service ? + + + Ouvrir les paramètres de Protection des données + + + Demande de vérification + + + Avant d'afficher une valeur protégée, Resgrid demande au membre un code issu de son application d'authentification. Vous pouvez désactiver cette demande pour certaines applications. + + + Laissez-la activée partout où c'est possible. La désactiver signifie que toute personne déjà connectée sur cet appareil peut révéler des données protégées sans prouver son identité — acceptable sur une console de régulation pendant une intervention, beaucoup moins sur un téléphone qui peut être perdu. + + + Site web Resgrid + + + Console de régulation + + + Application Responder + + + Application Unit + + + Application de commandement d'intervention + + + Intégrations API + + + Demander un code + + + Aucune demande + + + Enregistrer les paramètres de vérification + + + Modifier la demande de vérification pour ce service ? + + + Les paramètres de vérification n'ont pas pu être enregistrés. + + + Seul le membre gestionnaire du service peut modifier ceci. + + + Chaque modification est enregistrée avec son auteur et sa date. + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.it.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.it.resx index d630ec6c9..46222f494 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.it.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.it.resx @@ -1,4 +1,4 @@ - + @@ -403,4 +403,136 @@ Rimuovere questo contatto di emergenza? + + Componente aggiuntivo Protezione dati avanzata + + + Abbonamento + + + Aggiungi la Protezione dati avanzata + + + Gestisci la Protezione dati avanzata + + + all'anno + + + La Protezione dati avanzata cifra i campi sensibili del tuo dipartimento con una chiave riservata al solo dipartimento. Una volta attivata, i valori protetti restano nascosti finché un membro non si conferma con un secondo fattore, e rimangono cifrati in esportazioni, notifiche e report. + + + Cifratura dei campi sensibili per dipartimento + + + Conferma con secondo fattore prima di mostrare i valori protetti + + + Controllo su ciò che esce via e-mail, SMS e notifiche push + + + Una traccia di controllo di ogni lettura protetta, che non contiene valori + + + Acquista il componente + + + Gestisci il componente + + + Solo il membro amministratore del dipartimento può acquistare o annullare questo componente aggiuntivo. + + + La Protezione dati avanzata richiede un piano a pagamento. + + + L'acquisto del componente da solo non cifra nulla. Il dipartimento viene attivato in seguito, dalla pagina Protezione dati, nel momento che sceglierai. + + + Stato + + + Attivo + + + Annullato + + + Si rinnova il + + + Pagato fino al + + + Stato della protezione + + + Il tuo dipartimento non dispone di questo componente aggiuntivo. + + + Non abbiamo ancora ricevuto il pagamento. La protezione continua fino al {0}: pagando prima di tale data tutto resta cifrato e nulla viene decifrato. + + + La protezione termina il {0}. Fino ad allora tutto resta cifrato e il membro amministratore può ancora annullare questa scelta. + + + Annulla il componente + + + Annullando, la protezione resta attiva fino alla fine del periodo che hai pagato. Dopodiché una migrazione notturna decifra i dati del dipartimento riportandoli allo spazio di archiviazione normale. + + + Annullare la Protezione dati avanzata per questo dipartimento? + + + Apri le impostazioni di Protezione dati + + + Richiesta di verifica + + + Prima di mostrare un valore protetto, Resgrid chiede al membro un codice dalla sua app di autenticazione. Puoi disattivare questa richiesta per singole applicazioni. + + + Lasciala attiva ovunque sia possibile. Disattivarla significa che chiunque abbia già effettuato l'accesso su quel dispositivo può rivelare dati protetti senza dimostrare la propria identità: accettabile su una console di centrale durante un intervento, molto meno su un telefono che si può perdere. + + + Sito web Resgrid + + + Console di centrale + + + App Responder + + + App Unit + + + App di Comando Incidente + + + Integrazioni API + + + Chiedi un codice + + + Nessuna richiesta + + + Salva impostazioni di verifica + + + Modificare la richiesta di verifica per questo dipartimento? + + + Non è stato possibile salvare le impostazioni di verifica. + + + Solo il membro amministratore del dipartimento può modificarlo. + + + Ogni modifica viene registrata con autore e data. + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.pl.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.pl.resx index 361cd589d..692541c01 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.pl.resx @@ -1,4 +1,4 @@ - + @@ -403,4 +403,136 @@ Usunąć ten kontakt alarmowy? + + Dodatek Zaawansowana ochrona danych + + + Subskrypcja + + + Dodaj Zaawansowaną ochronę danych + + + Zarządzaj Zaawansowaną ochroną danych + + + rocznie + + + Zaawansowana ochrona danych szyfruje wrażliwe pola Twojej jednostki kluczem przechowywanym wyłącznie dla niej. Po włączeniu chronione wartości pozostają ukryte, dopóki członek nie potwierdzi tożsamości drugim składnikiem, i pozostają zaszyfrowane w eksportach, powiadomieniach i raportach. + + + Szyfrowanie wrażliwych pól w obrębie jednostki + + + Potwierdzenie drugim składnikiem przed pokazaniem chronionych wartości + + + Kontrola nad tym, co wychodzi w wiadomościach e-mail, SMS i powiadomieniach push + + + Ślad audytowy każdego chronionego odczytu, który sam nie zawiera żadnych wartości + + + Kup dodatek + + + Zarządzaj dodatkiem + + + Tylko członek zarządzający jednostką może kupić lub anulować ten dodatek. + + + Zaawansowana ochrona danych wymaga płatnego planu. + + + Sam zakup dodatku niczego nie szyfruje. Jednostka jest włączana później, na stronie ustawień Ochrony danych, w wybranym przez Ciebie momencie. + + + Status + + + Aktywny + + + Anulowany + + + Odnawia się + + + Opłacone do + + + Stan ochrony + + + Twoja jednostka nie posiada tego dodatku. + + + Nie otrzymaliśmy jeszcze Twojej płatności. Ochrona działa do {0} — opłacenie przed tą datą sprawia, że wszystko pozostaje zaszyfrowane i nic nie jest odszyfrowywane. + + + Ochrona kończy się {0}. Do tego czasu wszystko pozostaje zaszyfrowane, a członek zarządzający wciąż może to cofnąć. + + + Anuluj dodatek + + + Po anulowaniu ochrona działa do końca opłaconego okresu. Następnie nocna migracja odszyfrowuje dane jednostki i przywraca je do zwykłego magazynu. + + + Anulować Zaawansowaną ochronę danych dla tej jednostki? + + + Otwórz ustawienia Ochrony danych + + + Monit weryfikacyjny + + + Przed pokazaniem chronionej wartości Resgrid prosi członka o kod z aplikacji uwierzytelniającej. Ten monit można wyłączyć dla poszczególnych aplikacji. + + + Zostaw go włączonego wszędzie, gdzie to możliwe. Wyłączenie oznacza, że każdy już zalogowany na tym urządzeniu może ujawnić chronione dane bez potwierdzania tożsamości — do przyjęcia na konsoli dyspozytorskiej podczas zdarzenia, znacznie mniej na telefonie, który można zgubić. + + + Witryna Resgrid + + + Konsola dyspozytorska + + + Aplikacja Responder + + + Aplikacja Unit + + + Aplikacja dowodzenia zdarzeniem + + + Integracje API + + + Pytaj o kod + + + Bez pytania + + + Zapisz ustawienia weryfikacji + + + Zmienić monit weryfikacyjny dla tej jednostki? + + + Nie udało się zapisać ustawień weryfikacji. + + + Tylko członek zarządzający jednostką może to zmienić. + + + Każda zmiana jest rejestrowana wraz z autorem i czasem. + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.sv.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.sv.resx index 954b40c85..c8f6c5baf 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.sv.resx @@ -1,4 +1,4 @@ - + @@ -403,4 +403,136 @@ Ta bort denna nödkontakt? + + Tillägget Avancerat dataskydd + + + Prenumeration + + + Lägg till Avancerat dataskydd + + + Hantera Avancerat dataskydd + + + per år + + + Avancerat dataskydd krypterar din kårs känsliga fält med en nyckel som bara finns för din kår. När det har aktiverats förblir skyddade värden dolda tills en medlem bekräftar sig med en andra faktor, och de förblir krypterade i exporter, aviseringar och rapporter. + + + Kryptering av känsliga fält per kår + + + Bekräftelse med en andra faktor innan skyddade värden visas + + + Kontroll över vad som lämnar systemet via e-post, SMS och push-aviseringar + + + Ett granskningsspår över varje skyddad läsning, som självt inte innehåller några värden + + + Köp tillägget + + + Hantera tillägget + + + Endast kårens förvaltande medlem kan köpa eller säga upp detta tillägg. + + + Avancerat dataskydd kräver ett betalt abonnemang. + + + Att köpa tillägget krypterar ingenting i sig. Din kår aktiveras därefter, från sidan Dataskydd, vid en tidpunkt du väljer. + + + Status + + + Aktivt + + + Uppsagt + + + Förnyas den + + + Betalt till och med + + + Skyddets tillstånd + + + Din kår har inte detta tillägg. + + + Vi har ännu inte fått din betalning. Skyddet fortsätter till {0} — betalar du innan dess förblir allt krypterat och ingenting dekrypteras. + + + Skyddet upphör den {0}. Fram till dess förblir allt krypterat, och den förvaltande medlemmen kan fortfarande ångra detta. + + + Säg upp tillägget + + + Vid uppsägning fortsätter skyddet till slutet av den period du har betalat för. Därefter dekrypterar en nattlig migrering kårens data tillbaka till vanlig lagring. + + + Säga upp Avancerat dataskydd för den här kåren? + + + Öppna inställningarna för Dataskydd + + + Verifieringsfråga + + + Innan ett skyddat värde visas ber Resgrid medlemmen om en kod från autentiseringsappen. Du kan stänga av den frågan för enskilda applikationer. + + + Låt den vara på där du kan. Att stänga av den innebär att alla som redan är inloggade på enheten kan visa skyddade uppgifter utan att styrka vem de är — rimligt på en larmcentralskonsol under ett larm, betydligt mindre på en telefon som kan tappas bort. + + + Resgrids webbplats + + + Larmcentralskonsol + + + Responder-appen + + + Unit-appen + + + Appen för insatsledning + + + API-integrationer + + + Fråga efter kod + + + Ingen fråga + + + Spara verifieringsinställningar + + + Ändra verifieringsfrågan för den här kåren? + + + Verifieringsinställningarna kunde inte sparas. + + + Endast kårens förvaltande medlem kan ändra detta. + + + Varje ändring loggas med vem som gjorde den och när. + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.uk.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.uk.resx index 1711f1686..3feddef58 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.uk.resx @@ -1,4 +1,4 @@ - + @@ -403,4 +403,136 @@ Видалити цей екстрений контакт? + + Додаток «Розширений захист даних» + + + Підписка + + + Додати розширений захист даних + + + Керування розширеним захистом даних + + + на рік + + + Розширений захист даних шифрує конфіденційні поля вашого підрозділу ключем, який зберігається лише для нього. Після ввімкнення захищені значення залишаються прихованими, доки учасник не підтвердить себе другим фактором, і залишаються зашифрованими в експортах, сповіщеннях і звітах. + + + Шифрування конфіденційних полів у межах підрозділу + + + Підтвердження другим фактором перед показом захищених значень + + + Контроль над тим, що виходить електронною поштою, SMS і push-сповіщеннями + + + Журнал аудиту кожного захищеного читання, який сам не містить значень + + + Придбати додаток + + + Керувати додатком + + + Лише керівний учасник підрозділу може придбати або скасувати цей додаток. + + + Розширений захист даних потребує платного тарифу. + + + Придбання додатка саме собою нічого не шифрує. Підрозділ вмикається згодом, на сторінці налаштувань захисту даних, у зручний для вас час. + + + Стан + + + Активний + + + Скасовано + + + Поновлюється + + + Оплачено до + + + Стан захисту + + + Ваш підрозділ не має цього додатка. + + + Ми ще не отримали вашу оплату. Захист діє до {0} — якщо сплатити до цієї дати, усе залишиться зашифрованим і нічого не буде розшифровано. + + + Захист завершується {0}. До того часу все залишається зашифрованим, і керівний учасник ще може це скасувати. + + + Скасувати додаток + + + Після скасування захист діятиме до кінця оплаченого періоду. Потім нічна міграція розшифрує дані підрозділу та поверне їх до звичайного сховища. + + + Скасувати розширений захист даних для цього підрозділу? + + + Відкрити налаштування захисту даних + + + Запит підтвердження + + + Перш ніж показати захищене значення, Resgrid просить учасника ввести код із застосунку автентифікації. Цей запит можна вимкнути для окремих застосунків. + + + Залишайте його ввімкненим скрізь, де можливо. Вимкнення означає, що будь-хто, вже увійшовши на цьому пристрої, зможе розкрити захищені дані без підтвердження особи — прийнятно на диспетчерській консолі під час виїзду, значно менше на телефоні, який можна загубити. + + + Вебсайт Resgrid + + + Диспетчерська консоль + + + Застосунок Responder + + + Застосунок Unit + + + Застосунок керування інцидентом + + + Інтеграції API + + + Запитувати код + + + Без запиту + + + Зберегти налаштування підтвердження + + + Змінити запит підтвердження для цього підрозділу? + + + Не вдалося зберегти налаштування підтвердження. + + + Лише керівний учасник підрозділу може це змінити. + + + Кожну зміну записано разом з автором і часом. + diff --git a/Core/Resgrid.Model/AdpAddonBillingEvent.cs b/Core/Resgrid.Model/AdpAddonBillingEvent.cs index 38800972c..ad001fe5f 100644 --- a/Core/Resgrid.Model/AdpAddonBillingEvent.cs +++ b/Core/Resgrid.Model/AdpAddonBillingEvent.cs @@ -58,6 +58,13 @@ public class AdpAddonBillingEvent /// Provider dunning descriptor, carried for the audit line only (PaymentFailed). public string DunningState { get; set; } + /// + /// How this department pays. Resolved on the billing side, which is the only side that knows + /// whether the department is on a provider-charged subscription or invoiced on terms. Null + /// means "unchanged" — an event that does not carry it leaves the recorded mode alone. + /// + public AdpAddonBillingMode? BillingMode { get; set; } + /// True when this cancellation came from a chargeback or refund rather than the member. public bool IsChargeback { get; set; } diff --git a/Core/Resgrid.Model/AdpAddonBillingMode.cs b/Core/Resgrid.Model/AdpAddonBillingMode.cs new file mode 100644 index 000000000..c9b313a13 --- /dev/null +++ b/Core/Resgrid.Model/AdpAddonBillingMode.cs @@ -0,0 +1,21 @@ +namespace Resgrid.Model +{ + /// + /// How a department pays for the ADP addon, which is what decides how long protection survives a + /// lapse (ADP plan section 17.3). + /// + /// The two are not the same problem. A card that expires fails immediately and the provider's own + /// dunning retries settle it within days. An invoiced customer on NET terms has not failed + /// anything: no charge was ever attempted, the invoice simply is not due yet, and a purchase + /// order can sit in accounts payable for well over a month. Applying the card-shaped grace to + /// them would decrypt a paying customer's data while their cheque is in the post. + /// + public enum AdpAddonBillingMode + { + /// Card or wallet charged automatically by the provider; provider dunning applies. + Automatic = 1, + + /// Invoiced on payment terms (NET30/NET45/NET60); settled by the customer's finance team. + Invoiced = 2 + } +} diff --git a/Core/Resgrid.Model/AdpStepUpDecision.cs b/Core/Resgrid.Model/AdpStepUpDecision.cs new file mode 100644 index 000000000..b78bbb006 --- /dev/null +++ b/Core/Resgrid.Model/AdpStepUpDecision.cs @@ -0,0 +1,28 @@ +namespace Resgrid.Model +{ + /// + /// One policy snapshot's answer to two questions that must not be asked separately: does this + /// client have to step up, and what policy epoch does a grant issued without a step-up carry + /// (ADP plan section 3.3). + /// + /// They travel together on purpose. Read from two separate policy loads, a managing member could + /// revoke an exemption in between: the exemption check would pass against the OLD policy while + /// the grant took the NEW epoch — the very bump meant to kill grants issued under the looser + /// setting — and that grant would outlive the revocation. Taken from one snapshot, a revocation + /// landing mid-request leaves the grant stamped with the older epoch, which validation rejects. + /// + public class AdpStepUpDecision + { + /// True when the caller must complete a second factor before a grant is issued. + public bool StepUpRequired { get; set; } + + /// The policy epoch to stamp on a grant issued from this snapshot. + public long PolicyEpoch { get; set; } + + /// + /// The department's step-up window from this snapshot; zero when the department has no + /// policy row, in which case the caller falls back to the configured default. + /// + public int StepUpWindowMinutes { get; set; } + } +} diff --git a/Core/Resgrid.Model/AdpStepUpExemptClients.cs b/Core/Resgrid.Model/AdpStepUpExemptClients.cs new file mode 100644 index 000000000..aa6d858a9 --- /dev/null +++ b/Core/Resgrid.Model/AdpStepUpExemptClients.cs @@ -0,0 +1,94 @@ +using System; + +namespace Resgrid.Model +{ + /// + /// Which client applications a department has exempted from the Advanced Data Protection + /// step-up prompt (ADP plan section 3.3). + /// + /// The default is zero — nothing exempt, every app prompts for a second factor before a + /// protected value is revealed — and it stays that way until a department deliberately turns an + /// app off. That direction matters: a department that never touches this setting keeps the + /// stronger behaviour, and every weakening is an explicit, audited act by its managing member. + /// + /// The reason to allow it at all is operational rather than theoretical. A dispatcher working a + /// live incident cannot stop to read a code off a phone, and a prompt that lands mid-call is a + /// safety problem, not a security win — people work around it in ways far worse than the + /// exemption. So the choice is offered per app: a department can leave the prompt on for the web + /// site, where someone is doing administrative work at a desk, and take it off the dispatch + /// console, where seconds count. + /// + /// An exemption does NOT remove the grant. The caller still has to be signed in, still gets a + /// tenant-bound grant with an expiry and a policy epoch, and every read is still audited — what + /// changes is only whether a second factor is demanded before that grant is minted. Grants issued + /// this way are marked, so an auditor can tell them apart. + /// + /// Values mirror so the two never drift. + /// + [Flags] + public enum AdpStepUpExemptClients + { + /// The default and the recommendation: every client prompts. + None = 0, + + /// Core web site. + Web = 1 << UserSessionClientApplication.Web, + + Responder = 1 << UserSessionClientApplication.Responder, + + Unit = 1 << UserSessionClientApplication.Unit, + + /// The dispatch console — the case this setting exists for. + Dispatch = 1 << UserSessionClientApplication.Dispatch, + + /// Incident Command. + Command = 1 << UserSessionClientApplication.Command, + + /// + /// Direct API callers. Deliberately offered last and separately: an API integration is not a + /// person under time pressure, so the operational argument for exempting it is much weaker. + /// + Api = 1 << UserSessionClientApplication.Api + } + + /// Maps a client application onto its exemption flag. + public static class AdpStepUpExemptClientsExtensions + { + /// + /// True when this department has exempted the given client from the step-up prompt. + /// + /// BigBoard and MCP are never exemptable and always return false. BigBoard is an unattended + /// wall display with no one to prompt and no business seeing protected values at all — it + /// steps DOWN to safe projections (plan 7.3) rather than up. MCP is automated. An unknown or + /// legacy client is likewise never exempt: a client that cannot identify itself must not + /// inherit somebody else's exemption. + /// + public static bool IsExempt(this AdpStepUpExemptClients exemptions, UserSessionClientApplication client) + { + switch (client) + { + case UserSessionClientApplication.Web: + case UserSessionClientApplication.Responder: + case UserSessionClientApplication.Unit: + case UserSessionClientApplication.Dispatch: + case UserSessionClientApplication.Command: + case UserSessionClientApplication.Api: + return (exemptions & (AdpStepUpExemptClients)(1 << (int)client)) != 0; + + default: + return false; + } + } + + /// Strips any bit that does not map to an exemptable client, so a stored value cannot + /// carry meaning nothing reads — and cannot quietly acquire it if the enum grows. + public static AdpStepUpExemptClients Sanitize(this AdpStepUpExemptClients exemptions) + { + var allowed = AdpStepUpExemptClients.Web | AdpStepUpExemptClients.Responder | + AdpStepUpExemptClients.Unit | AdpStepUpExemptClients.Dispatch | + AdpStepUpExemptClients.Command | AdpStepUpExemptClients.Api; + + return exemptions & allowed; + } + } +} diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index c3c1bfb39..7cdc342d4 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -1,4 +1,4 @@ -namespace Resgrid.Model +namespace Resgrid.Model { public enum AuditLogTypes { @@ -193,6 +193,10 @@ public enum AuditLogTypes ModerationRequestCompleted, ModerationEvidenceDownloaded, PasswordResetByAdministrator, - UserAuthenticationSessionsRevoked + UserAuthenticationSessionsRevoked, + // Advanced Data Protection: a department releasing (or restoring) the step-up prompt for a + // client app. Weakening a protection control is exactly the kind of change that has to be + // answerable later, so it is audited with the before and after mask. + DataProtectionStepUpExemptionsChanged } } diff --git a/Core/Resgrid.Model/DepartmentDataProtectionPolicy.cs b/Core/Resgrid.Model/DepartmentDataProtectionPolicy.cs index 55a4185a4..acd0e857e 100644 --- a/Core/Resgrid.Model/DepartmentDataProtectionPolicy.cs +++ b/Core/Resgrid.Model/DepartmentDataProtectionPolicy.cs @@ -111,6 +111,57 @@ public class DepartmentDataProtectionPolicy : IEntity [ProtoMember(25)] public DateTime? LastBillingEventOccurredOn { get; set; } + /// + /// End of the addon cycle billing has actually been paid for (M0144). The anchor every grace + /// calculation starts from, and the only date that says "this department is paid up". + /// + [ProtoMember(26)] + public DateTime? AddonPaidThroughOn { get; set; } + + /// + /// - automatic card billing or invoiced payment terms. It + /// selects which default grace applies, because a failed card and an unpaid NET45 invoice are + /// not the same event even though both arrive as "not paid yet". + /// + [ProtoMember(27)] + public int? AddonBillingMode { get; set; } + + /// + /// When the CURRENT lapse began - the first payment failure of this episode, or the first + /// event seen after the paid-through date passed. Null once payment lands again. + /// + [ProtoMember(28)] + public DateTime? AddonDunningStartedOn { get; set; } + + /// + /// The hard floor: offboarding for a NON-VOLUNTARY lapse is never scheduled before this + /// instant, whatever the provider says. Computed ONCE per lapse, deliberately — recomputing + /// it on each retry webhook would let a card that fails forever renew its own grace forever. + /// Cleared when a payment lands. + /// + [ProtoMember(29)] + public DateTime? AddonGraceEndsOn { get; set; } + + /// + /// Per-department grace in days, overriding the configured default (M0144). For the customer + /// whose purchase order genuinely takes ninety days. Support sets it with a reason; it is + /// clamped to DataProtectionConfig.AddonMaxGraceDays so a mistyped value cannot hand + /// out protection indefinitely. + /// + [ProtoMember(30)] + public int? AddonGraceDaysOverride { get; set; } + + /// + /// bitmask — which client apps this department has + /// released from the step-up prompt (M0145). Zero, the default, means every app prompts. + /// + /// Only ever moves by an explicit act of the department's managing member, and every change + /// is audited and bumps the policy epoch: tightening it has to invalidate the grants the + /// looser setting already handed out, or the change would not take effect until they expired. + /// + [ProtoMember(31)] + public int StepUpExemptClients { get; set; } + /// Department-local overnight migration window start, "HH:mm" (default 22:00). [MaxLength(5)] [ProtoMember(15)] diff --git a/Core/Resgrid.Model/ProtectedDataGrant.cs b/Core/Resgrid.Model/ProtectedDataGrant.cs index b874a4547..75bcff6a8 100644 --- a/Core/Resgrid.Model/ProtectedDataGrant.cs +++ b/Core/Resgrid.Model/ProtectedDataGrant.cs @@ -36,6 +36,14 @@ public class ProtectedDataGrant /// UTC instant the fresh MFA step-up completed (mfa_at). Absolute; never refreshed. public DateTime MfaAtUtc { get; set; } + /// + /// True when this grant was issued WITHOUT a second factor because the department exempted + /// the calling client (). Carried explicitly rather than + /// inferred from , which records when the grant was minted either way — + /// an auditor asking "did somebody actually step up for this?" needs a straight answer. + /// + public bool StepUpExempt { get; set; } + /// UTC issuance instant (iat). public DateTime IssuedAtUtc { get; set; } diff --git a/Core/Resgrid.Model/ProtectedDataGrantIssueRequest.cs b/Core/Resgrid.Model/ProtectedDataGrantIssueRequest.cs index ce929740f..a019e76e4 100644 --- a/Core/Resgrid.Model/ProtectedDataGrantIssueRequest.cs +++ b/Core/Resgrid.Model/ProtectedDataGrantIssueRequest.cs @@ -32,6 +32,9 @@ public class ProtectedDataGrantIssueRequest /// UTC instant the MFA step-up completed. The mfa_at claim; never refreshed later. public DateTime MfaAtUtc { get; set; } + + /// Set when the department exempted the calling client from the step-up prompt. + public bool StepUpExempt { get; set; } } /// Result of a successful grant issuance. The token is sensitive-in-transit but value-free. diff --git a/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionBulkRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionBulkRepository.cs index 44ba6618e..8c79a96bd 100644 --- a/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionBulkRepository.cs +++ b/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionBulkRepository.cs @@ -49,6 +49,15 @@ Task CountBinaryResidueAsync(AdpTableBinding binding, int departmentId, bo /// Residue scan for companion-column fields: enrollment residue = typed column still non-null; /// offboarding residue = companion envelope column still non-null. /// + /// + /// Rotation residue: values that ARE enveloped but reference a key version other than + /// . A rotation ends with everything still enveloped, so + /// the enveloped/not-enveloped counts prove nothing about it — this is the gate that says the + /// superseded version is genuinely unreferenced and can be retired. + /// + Task CountSupersededKeyVersionResidueAsync(AdpTableBinding binding, int departmentId, + int targetKeyVersion, CancellationToken cancellationToken = default); + Task CountCompanionResidueAsync(AdpTableBinding binding, int departmentId, bool enveloped, CancellationToken cancellationToken = default); } diff --git a/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs b/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs index 8abb1f2fb..4c0af120f 100644 --- a/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs @@ -126,6 +126,83 @@ Task GetMigrationProgressAsync(int departmentId, Task ApplyAddonBillingEventAsync(AdpAddonBillingEvent billingEvent, CancellationToken cancellationToken = default); + /// + /// True when the calling client must complete a second factor before a grant is issued + /// (plan 3.3). Departments may exempt named apps; everything else prompts. + /// + /// Fails CLOSED: a lookup that throws, a department with no policy, and any client that is + /// not exemptable all read as "step up required". The safe answer to "I am not sure" is the + /// prompt. + /// + Task IsStepUpRequiredForClientAsync(int departmentId, UserSessionClientApplication client, + bool bypassCache = false); + + /// + /// The step-up decision AND the policy epoch a step-up-exempt grant must carry, both taken + /// from a SINGLE policy read (plan 3.3). + /// + /// Callers that issue a grant must use this rather than checking the exemption and reading + /// the epoch separately: a revocation landing between two reads would pass the check against + /// the old policy and stamp the grant with the new epoch, so the grant would survive the + /// revocation that was supposed to kill it. + /// + /// Fails CLOSED, the same as . + /// + Task GetStepUpDecisionForClientAsync(int departmentId, + UserSessionClientApplication client, bool bypassCache = false); + + /// The department's current per-app step-up exemptions (plan 3.3). + Task GetStepUpExemptClientsAsync(int departmentId, bool bypassCache = false); + + /// + /// Replaces the department's step-up exemptions. Managing member only, and every change bumps + /// the policy epoch: tightening the setting has to invalidate the grants the looser one + /// already issued, or the change would not bite until they expired on their own. + /// + /// The caller is responsible for writing the audit record — it needs the request's IP and + /// user agent, which this layer does not have. + /// + Task SetStepUpExemptClientsAsync(int departmentId, + AdpStepUpExemptClients exemptions, string requestingUserId, CancellationToken cancellationToken = default); + + /// + /// Queues a department key rotation (plan 11.3): provisions the next key version, moves the + /// current one to Retiring, and sets the department Rotating so the overnight worker re-keys + /// every envelope. The superseded version is only retired after verification proves nothing + /// still references it. + /// + /// Rotation is a RESGRID operation, not a customer one - it runs under the CryptoOps identity + /// and is triggered from BackOffice. Protection, grants and reads continue throughout: the + /// department sees a locked overnight window, exactly as it saw at enrollment. + /// + /// Only an Enabled department can rotate. Anything mid-migration already owns the cursor. + /// + Task QueueKeyRotationAsync(int departmentId, + string requestingUserId, CancellationToken cancellationToken = default); + + /// + /// Puts a Failed migration back in the queue, resuming from its stored cursor (plan 7.4). + /// + /// A Failed run is deliberately never auto-resumed: whatever stopped it is usually still + /// there, and a worker that retries on its own turns one bad night into a loop. An operator + /// clears the cause and calls this. The migration kind on the policy decides which state it + /// returns to, so an offboarding resumes decrypting and an enrollment resumes encrypting. + /// + Task RetryFailedMigrationAsync(int departmentId, + string requestingUserId, CancellationToken cancellationToken = default); + + /// + /// Stops an in-flight migration window (plan 7.4). Releases the department operation lock as + /// Aborted, so the worker stops at its next checkpoint, and marks the run Failed with an + /// operator error code — which is the state that can be retried from the cursor. + /// + /// Nothing already written is undone. Every row the run has processed stays exactly as it + /// is; that is what makes stopping safe at any point, and it is why this is an abort rather + /// than a rollback. + /// + Task AbortActiveMigrationAsync(int departmentId, string requestingUserId, + CancellationToken cancellationToken = default); + /// Atomically bumps the department policy epoch (grant revocation); returns the new epoch. Task IncrementPolicyEpochAsync(int departmentId, string updatedByUserId, CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/Services/IDepartmentKeyService.cs b/Core/Resgrid.Model/Services/IDepartmentKeyService.cs index f01852a2d..a01079deb 100644 --- a/Core/Resgrid.Model/Services/IDepartmentKeyService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentKeyService.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -27,6 +28,13 @@ public interface IDepartmentKeyService /// Task ProvisionNextKeyVersionAsync(int departmentId, CancellationToken cancellationToken = default); + /// + /// Every key version the department has ever held, in any status. Used by rotation to find the + /// superseded versions it may retire, and by support to answer "which versions exist" without + /// touching key material. + /// + Task> GetAllVersionsAsync(int departmentId); + /// /// Marks a Retiring version Retired once rotation re-encryption has verified no envelope still /// references it. Never deletes the row. diff --git a/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs b/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs index fae8c2dae..78bc9cb3b 100644 --- a/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs +++ b/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs @@ -167,10 +167,32 @@ public async Task VerifyAsync(AdpMigrationNightContext context, Cancellati { var enveloped = context.Kind == DepartmentDataProtectionMigrationKind.Offboarding; + // A rotation ends with everything still enveloped, so "is it enveloped" proves nothing. + // What it has to prove is that no envelope still references a SUPERSEDED key version - + // otherwise the old version could not be retired without making those rows unreadable. + var rotationTargetVersion = context.Kind == DepartmentDataProtectionMigrationKind.Rotation + ? context.TargetKeyVersion ?? 0 + : 0; + foreach (var binding in BindingsFor(context)) { cancellationToken.ThrowIfCancellationRequested(); + if (rotationTargetVersion > 0) + { + var staleVersions = await _bulkRepository.CountSupersededKeyVersionResidueAsync(binding, + context.DepartmentId, rotationTargetVersion, cancellationToken); + + if (staleVersions > 0) + { + Logging.LogError($"ADP rotation verification failed for department {context.DepartmentId} table {binding.TableName}: {staleVersions} value(s) still on a superseded key version."); + await MarkVerificationAsync(context, DepartmentDataProtectionVerificationState.Failed, complete: false, cancellationToken); + return false; + } + + continue; + } + var textResidue = await _bulkRepository.CountTextResidueAsync(binding, context.DepartmentId, enveloped, cancellationToken); var binaryResidue = await _bulkRepository.CountBinaryResidueAsync(binding, context.DepartmentId, enveloped, cancellationToken); var companionResidue = await _bulkRepository.CountCompanionResidueAsync(binding, context.DepartmentId, enveloped, cancellationToken); @@ -298,9 +320,17 @@ private async Task EncryptRowColumnAsync(Func> byte[] targetDek, int keyVersion, AdpMigrationNightContext context, AdpColumnSpec spec, AdpBulkFieldRow row, Dictionary setValues) { + // A ROTATION re-encrypts every envelope under the new key version. It rides the encryption + // path rather than having one of its own because the two differ by a single step: the + // validation decrypt below already produces the plaintext, so re-keying is just encrypting + // that same plaintext again under the new version. The key version is NOT an AAD component + // (the AAD binds department, field and row), so a re-keyed envelope stays bound to exactly + // what it was bound to before. + var isRekeying = context.Kind == DepartmentDataProtectionMigrationKind.Rotation; + // Resolves the DEK for the key version an EXISTING envelope references; an unparseable // header or an unknown version reads as corrupt/foreign and halts the run (fail closed). - async Task ValidationDekForTextAsync(string envelope) + async Task<(byte[] Dek, int Version)> ValidationDekForTextAsync(string envelope) { if (!ProtectedDataEnvelope.TryParse(envelope, out _, out var envelopeKeyVersion, out _)) throw new CryptographicException("Prefixed value is not a parseable ADP envelope; treating as corrupt."); @@ -309,7 +339,7 @@ async Task ValidationDekForTextAsync(string envelope) if (validationDek == null) throw new CryptographicException("Envelope references an unknown department key version."); - return validationDek; + return (validationDek, envelopeKeyVersion); } switch (spec.StorageKind) @@ -324,8 +354,16 @@ async Task ValidationDekForTextAsync(string envelope) { // Validate against THIS department's AAD with the key version that wrote the // envelope; a mismatch throws (foreign envelope). - var validationDek = await ValidationDekForTextAsync(value); - _cryptoService.DecryptText(validationDek, value, context.DepartmentId, spec.FieldId, row.RowKey); + var (validationDek, envelopeKeyVersion) = await ValidationDekForTextAsync(value); + var plaintext = _cryptoService.DecryptText(validationDek, value, context.DepartmentId, spec.FieldId, row.RowKey); + + if (isRekeying && envelopeKeyVersion != keyVersion) + { + setValues[spec.ColumnName] = _cryptoService.EncryptText(targetDek, keyVersion, plaintext, + context.DepartmentId, spec.FieldId, row.RowKey); + return ColumnOutcome.Changed; + } + return ColumnOutcome.AlreadyInTargetState; } @@ -349,7 +387,15 @@ async Task ValidationDekForTextAsync(string envelope) if (validationDek == null) throw new CryptographicException("Envelope references an unknown department key version."); - _cryptoService.DecryptBinary(validationDek, value, context.DepartmentId, spec.FieldId, row.RowKey); + var plaintext = _cryptoService.DecryptBinary(validationDek, value, context.DepartmentId, spec.FieldId, row.RowKey); + + if (isRekeying && envelopeKeyVersion != keyVersion) + { + setValues[spec.ColumnName] = _cryptoService.EncryptBinary(targetDek, keyVersion, plaintext, + context.DepartmentId, spec.FieldId, row.RowKey); + return ColumnOutcome.Changed; + } + return ColumnOutcome.AlreadyInTargetState; } @@ -374,8 +420,16 @@ async Task ValidationDekForTextAsync(string envelope) if (!string.IsNullOrEmpty(companion)) { - var validationDek = await ValidationDekForTextAsync(companion); - _cryptoService.DecryptText(validationDek, companion, context.DepartmentId, spec.FieldId, row.RowKey); + var (validationDek, envelopeKeyVersion) = await ValidationDekForTextAsync(companion); + var plaintext = _cryptoService.DecryptText(validationDek, companion, context.DepartmentId, spec.FieldId, row.RowKey); + + if (isRekeying && envelopeKeyVersion != keyVersion) + { + setValues[spec.CompanionColumn] = _cryptoService.EncryptText(targetDek, keyVersion, plaintext, + context.DepartmentId, spec.FieldId, row.RowKey); + return ColumnOutcome.Changed; + } + return ColumnOutcome.AlreadyInTargetState; } diff --git a/Core/Resgrid.Services/DepartmentDataProtectionService.cs b/Core/Resgrid.Services/DepartmentDataProtectionService.cs index a36280c0d..cb12dae8e 100644 --- a/Core/Resgrid.Services/DepartmentDataProtectionService.cs +++ b/Core/Resgrid.Services/DepartmentDataProtectionService.cs @@ -32,13 +32,18 @@ public class DepartmentDataProtectionService : IDepartmentDataProtectionService private readonly ICacheProvider _cacheProvider; private readonly IProtectedFieldCatalog _fieldCatalog; private readonly IDepartmentDataProtectionMigrationRepository _migrationRepository; + private readonly IDepartmentLockService _departmentLockService; + private readonly IDepartmentKeyService _keyService; public DepartmentDataProtectionService(IDepartmentDataProtectionPolicyRepository policyRepository, IDepartmentProtectedDataEgressPolicyRepository egressPolicyRepository, IDepartmentsService departmentsService, IFeatureToggleService featureToggleService, ISubscriptionsService subscriptionsService, ICacheProvider cacheProvider, IProtectedFieldCatalog fieldCatalog, - IDepartmentDataProtectionMigrationRepository migrationRepository) + IDepartmentDataProtectionMigrationRepository migrationRepository, + IDepartmentLockService departmentLockService, IDepartmentKeyService keyService) { + _departmentLockService = departmentLockService; + _keyService = keyService; _policyRepository = policyRepository; _egressPolicyRepository = egressPolicyRepository; _departmentsService = departmentsService; @@ -394,20 +399,37 @@ public async Task ApplyAddonBillingEve { case AdpAddonBillingEventKind.Activated: case AdpAddonBillingEventKind.Renewed: - // No crypto change (plan 17.3). Renewal after a cancellation is the provider - // telling us the subscription is alive again, so a scheduled offboarding that - // has not started yet is withdrawn — this is what settles an out-of-order - // Cancelled-then-Renewed pair on the provider's current truth. + // No crypto change (plan 17.3). A payment landing ENDS any lapse: the grace + // floor and the dunning marker are cleared, and a scheduled offboarding that + // has not started yet is withdrawn. + // + // This is the whole recovery path for a late payer. An invoiced department + // whose NET45 cheque clears on day fifty arrives here as a Renewed, and the + // offboarding scheduled at their grace floor is withdrawn before its date - + // nothing was ever decrypted, and nobody had to phone support. + policy.AddonDunningStartedOn = null; + policy.AddonGraceEndsOn = null; + if ((DepartmentDataProtectionState)policy.State == DepartmentDataProtectionState.OffboardingScheduled) result = await RevokeScheduledOffboardingForBillingAsync(billingEvent.DepartmentId, cancellationToken); break; case AdpAddonBillingEventKind.PaymentFailed: - // Dunning changes nothing about protection (plan 17.3). Recorded for the - // audit line and nothing else; exhausted dunning arrives later as Cancelled. + // Protection continues untouched (plan 17.3). What this DOES do is fix the + // floor beneath which offboarding can later be scheduled, once, at the start + // of the lapse - see BeginLapseIfNewAsync for why once and not per event. + BeginLapseIfNew(policy, billingEvent); + Logging.LogInfo($"ADP addon payment failed for department {billingEvent.DepartmentId} " + - $"(provider {billingEvent.ProviderName}, dunning {billingEvent.DunningState}); protection continues."); - break; + $"(provider {billingEvent.ProviderName}, dunning {billingEvent.DunningState}); protection " + + $"continues to at least {policy.AddonGraceEndsOn:o}."); + + // An exhausted dunning cycle is a cancellation in everything but name, and + // some providers report it that way rather than sending a separate cancel. + if (!billingEvent.IsDunningExhausted) + break; + + goto case AdpAddonBillingEventKind.Cancelled; case AdpAddonBillingEventKind.Cancelled: var source = billingEvent.IsChargeback @@ -416,9 +438,7 @@ public async Task ApplyAddonBillingEve ? DepartmentDataProtectionOffboardingSource.DunningExhausted : DepartmentDataProtectionOffboardingSource.UserCancelled; - // A chargeback or refund ends the paid cycle immediately, but offboarding - // still runs through the normal worker path — never an instant crypto flip. - var effectiveOn = billingEvent.EffectiveEndUtc ?? DateTime.UtcNow; + var effectiveOn = ResolveOffboardingEffectiveOn(policy, billingEvent, source); // Already scheduled: the provider is repeating itself. Re-scheduling would // move a date a member may have been told, so it is left alone. @@ -436,7 +456,7 @@ public async Task ApplyAddonBillingEve break; } - await RecordBillingEventAsync(billingEvent, cancellationToken); + await RecordBillingEventAsync(billingEvent, policy, cancellationToken); return result; } catch (Exception ex) @@ -446,6 +466,73 @@ public async Task ApplyAddonBillingEve } } + /// + /// The grace a lapse gets, in days: the department's own override if support set one, else the + /// configured default for how it pays. Clamped so a mistyped override cannot hand out + /// protection indefinitely, and floored at zero so a negative one cannot backdate the floor + /// into the past. + /// + private static int ResolveGraceDays(DepartmentDataProtectionPolicy policy) + { + var configured = (AdpAddonBillingMode?)policy.AddonBillingMode == AdpAddonBillingMode.Invoiced + ? Config.DataProtectionConfig.AddonInvoicedBillingGraceDays + : Config.DataProtectionConfig.AddonAutomaticBillingGraceDays; + + var days = policy.AddonGraceDaysOverride ?? configured; + var ceiling = Math.Max(0, Config.DataProtectionConfig.AddonMaxGraceDays); + + return Math.Min(Math.Max(0, days), ceiling); + } + + /// + /// Opens a lapse and fixes its grace floor — ONCE. A failing card produces a payment-failure + /// webhook on every retry, and recomputing the floor on each one would push it forward + /// indefinitely: a department whose card never works again would keep protection forever + /// while paying nothing. So the floor is set on the first failure of an episode and left + /// alone until a payment lands and clears it. + /// + /// The anchor is the paid-through date, not "now": what the department bought runs out when + /// it runs out, and the grace is added to that. Only when we have no paid-through date at all + /// does the failure's own timestamp stand in. + /// + private static void BeginLapseIfNew(DepartmentDataProtectionPolicy policy, AdpAddonBillingEvent billingEvent) + { + if (policy.AddonGraceEndsOn.HasValue && policy.AddonDunningStartedOn.HasValue) + return; + + var occurredOn = billingEvent.OccurredOnUtc != default ? billingEvent.OccurredOnUtc : DateTime.UtcNow; + + policy.AddonDunningStartedOn = occurredOn; + policy.AddonGraceEndsOn = (policy.AddonPaidThroughOn ?? occurredOn).AddDays(ResolveGraceDays(policy)); + } + + /// + /// When protection actually ends for a cancellation. + /// + /// A member who cancels gets exactly what they paid for and not a day more — they asked to + /// stop, so the provider's end-of-cycle date stands. A chargeback ends it now; that is a + /// dispute, not a slow payment. Between those two sits the case this exists for: a department + /// that simply has not paid yet, where the provider's end date is only a statement about + /// billing, and using it directly would decrypt a customer whose invoice is still inside its + /// terms. There, the grace floor wins. + /// + private static DateTime ResolveOffboardingEffectiveOn(DepartmentDataProtectionPolicy policy, + AdpAddonBillingEvent billingEvent, DepartmentDataProtectionOffboardingSource source) + { + var providerEnd = billingEvent.EffectiveEndUtc ?? DateTime.UtcNow; + + if (source != DepartmentDataProtectionOffboardingSource.DunningExhausted) + return providerEnd; + + // The floor may not have been set if the provider never sent a payment failure before + // giving up, so compute it here rather than trusting it to exist. + if (!policy.AddonGraceEndsOn.HasValue) + BeginLapseIfNew(policy, billingEvent); + + var floor = policy.AddonGraceEndsOn ?? providerEnd; + return floor > providerEnd ? floor : providerEnd; + } + /// /// Withdraws an offboarding that billing has superseded. Deliberately NOT RevokeOffboardingAsync: /// that one is the member-facing command and enforces managing-member authorization, which a @@ -479,12 +566,27 @@ private async Task RevokeScheduledOffb /// Stamps the subscription reference and the applied event id. The id is what makes a repeat /// of the same webhook a no-op above. /// - private async Task RecordBillingEventAsync(AdpAddonBillingEvent billingEvent, CancellationToken cancellationToken) + private async Task RecordBillingEventAsync(AdpAddonBillingEvent billingEvent, + DepartmentDataProtectionPolicy applied, CancellationToken cancellationToken) { var policy = await _policyRepository.GetByDepartmentIdAsync(billingEvent.DepartmentId); if (policy == null) return; + // The lapse fields were decided against the row the rules ran on; carry them across + // rather than recomputing, so a state transition in between cannot change the answer. + policy.AddonDunningStartedOn = applied?.AddonDunningStartedOn; + policy.AddonGraceEndsOn = applied?.AddonGraceEndsOn; + + if (billingEvent.BillingMode.HasValue) + policy.AddonBillingMode = (int)billingEvent.BillingMode.Value; + + // Only ever moves forward. A late-arriving event from an older cycle must not shorten + // what the department has already been told it is paid up to. + if (billingEvent.PaidThroughUtc.HasValue && + (!policy.AddonPaidThroughOn.HasValue || billingEvent.PaidThroughUtc.Value > policy.AddonPaidThroughOn.Value)) + policy.AddonPaidThroughOn = billingEvent.PaidThroughUtc.Value; + if (!string.IsNullOrWhiteSpace(billingEvent.ExternalSubscriptionRef)) policy.AddonBillingReference = billingEvent.ExternalSubscriptionRef; @@ -695,6 +797,247 @@ public async Task GetEnrollmentPreflightAsync(int depart return preflight; } + public async Task GetStepUpExemptClientsAsync(int departmentId, bool bypassCache = false) + { + try + { + var policy = await GetPolicyByDepartmentIdAsync(departmentId, bypassCache); + return ((AdpStepUpExemptClients)(policy?.StepUpExemptClients ?? 0)).Sanitize(); + } + catch (Exception ex) + { + // Fail closed: an unknown setting must read as "nothing is exempt", which prompts. + Logging.LogException(ex, $"ADP step-up exemption lookup failed for department {departmentId}; reporting none exempt."); + return AdpStepUpExemptClients.None; + } + } + + public async Task IsStepUpRequiredForClientAsync(int departmentId, UserSessionClientApplication client, + bool bypassCache = false) + { + var decision = await GetStepUpDecisionForClientAsync(departmentId, client, bypassCache); + return decision.StepUpRequired; + } + + public async Task GetStepUpDecisionForClientAsync(int departmentId, + UserSessionClientApplication client, bool bypassCache = false) + { + try + { + // ONE read backs the whole decision. The epoch a grant is stamped with has to come + // from the same snapshot that said the client was exempt, or a revocation arriving + // between two reads would mint a grant carrying the epoch its own revocation bumped. + var policy = await GetPolicyByDepartmentIdAsync(departmentId, bypassCache); + var exemptions = ((AdpStepUpExemptClients)(policy?.StepUpExemptClients ?? 0)).Sanitize(); + + return new AdpStepUpDecision + { + StepUpRequired = policy == null || !exemptions.IsExempt(client), + PolicyEpoch = policy?.PolicyEpoch ?? 0, + StepUpWindowMinutes = policy?.StepUpWindowMinutes ?? 0 + }; + } + catch (Exception ex) + { + // Fail closed: an unknown setting must read as "nothing is exempt", which prompts. + Logging.LogException(ex, $"ADP step-up decision lookup failed for department {departmentId}; requiring step up."); + return new AdpStepUpDecision { StepUpRequired = true }; + } + } + + public async Task SetStepUpExemptClientsAsync(int departmentId, + AdpStepUpExemptClients exemptions, string requestingUserId, CancellationToken cancellationToken = default) + { + try + { + // Weakening a protection control is a managing-member decision, the same identity that + // bought the addon and enrolled the department - not any administrator. + var managingCheck = await VerifyManagingMemberAsync(departmentId, requestingUserId); + if (managingCheck != null) + return managingCheck.Value; + + var policy = await _policyRepository.GetByDepartmentIdAsync(departmentId); + if (policy == null) + return DepartmentDataProtectionEnrollmentResult.InvalidState; + + var sanitized = exemptions.Sanitize(); + if ((AdpStepUpExemptClients)policy.StepUpExemptClients == sanitized) + return DepartmentDataProtectionEnrollmentResult.Queued; + + policy.StepUpExemptClients = (int)sanitized; + policy.UpdatedOn = DateTime.UtcNow; + policy.UpdatedByUserId = requestingUserId; + await _policyRepository.SaveOrUpdateAsync(policy, cancellationToken); + + await InvalidateProtectionCacheAsync(departmentId); + + // The epoch bump is what makes a TIGHTENING take effect now rather than whenever the + // last loosely-issued grant happened to expire. It is applied in both directions so + // the rule stays simple and there is never a window where the two disagree. + await IncrementPolicyEpochAsync(departmentId, requestingUserId, cancellationToken); + + Logging.LogInfo($"ADP step-up exemptions for department {departmentId} set to {sanitized} by {requestingUserId}."); + + return DepartmentDataProtectionEnrollmentResult.Queued; + } + catch (Exception ex) + { + Logging.LogException(ex, $"ADP SetStepUpExemptClientsAsync failed for department {departmentId}"); + return DepartmentDataProtectionEnrollmentResult.Failed; + } + } + + public async Task QueueKeyRotationAsync(int departmentId, + string requestingUserId, CancellationToken cancellationToken = default) + { + try + { + var policy = await GetPolicyByDepartmentIdAsync(departmentId, bypassCache: true); + if (policy == null) + return DepartmentDataProtectionEnrollmentResult.InvalidState; + + // Enabled only. A department mid-enrollment, mid-upgrade or mid-offboarding already + // has a cursor in flight, and a second sweep over the same rows under a different key + // would race the first. + if ((DepartmentDataProtectionState)policy.State != DepartmentDataProtectionState.Enabled) + return DepartmentDataProtectionEnrollmentResult.InvalidState; + + // The key is provisioned BEFORE the state moves. Provisioning is the step that can + // fail on a KMS outage, and failing it here leaves the department Enabled with no new + // version; failing it after the transition would park a department in Rotating with + // no version to rotate to. + var newKey = await _keyService.ProvisionNextKeyVersionAsync(departmentId, cancellationToken); + if (newKey == null) + return DepartmentDataProtectionEnrollmentResult.Failed; + + var rows = await _policyRepository.TryTransitionStateAsync(departmentId, + DepartmentDataProtectionState.Enabled, DepartmentDataProtectionState.Rotating, + (int)DepartmentDataProtectionMigrationKind.Rotation, requestingUserId, cancellationToken); + + if (rows == 0) + { + // Losing this race is NOT a no-op: provisioning already activated the new version + // and moved the previous one to Retiring, so the department stays Enabled holding + // a key version no sweep is queued to apply. New writes take the new version while + // older envelopes still name the Retiring one — readable, and cleared by the next + // rotation, but an operator has to be able to see that it happened. + Logging.LogError($"ADP key rotation for department {departmentId} lost the Enabled->Rotating transition after key v{newKey.Version} was activated; the department holds an unswept key version."); + return DepartmentDataProtectionEnrollmentResult.InvalidState; + } + + await InvalidateProtectionCacheAsync(departmentId); + + // Value-free: department, key version and who asked. Never key material. + Logging.LogInfo($"ADP key rotation queued for department {departmentId} to key v{newKey.Version} by {requestingUserId}."); + + return DepartmentDataProtectionEnrollmentResult.Queued; + } + catch (Exception ex) + { + Logging.LogException(ex, $"ADP QueueKeyRotationAsync failed for department {departmentId}"); + return DepartmentDataProtectionEnrollmentResult.Failed; + } + } + + public async Task RetryFailedMigrationAsync(int departmentId, + string requestingUserId, CancellationToken cancellationToken = default) + { + try + { + var policy = await GetPolicyByDepartmentIdAsync(departmentId, bypassCache: true); + if (policy == null) + return DepartmentDataProtectionEnrollmentResult.InvalidState; + + if ((DepartmentDataProtectionState)policy.State != DepartmentDataProtectionState.Failed) + return DepartmentDataProtectionEnrollmentResult.InvalidState; + + // A run that failed without recording what it was doing cannot be resumed safely: + // encrypting and decrypting from the same cursor are opposite operations. + if (!policy.ActiveMigrationKind.HasValue) + return DepartmentDataProtectionEnrollmentResult.InvalidState; + + // Each kind resumes into the state its own sweep runs from. An offboarding that came + // back as EnrollmentQueued would re-encrypt a department on its way out, and a + // rotation that did would re-run enrollment over an already-protected corpus. + // + // CatalogUpgrade resumes into Encrypting, the state the nightly sweep queues it into. + // Sending it to EnrollmentQueued would be worse than a wasted pass: the worker's + // enrollment path rewrites ActiveMigrationKind to Enrollment on its first transition, + // which both loses the upgrade's narrower field scope and drops enforcement — an + // Encrypting department only enforces while its kind still reads CatalogUpgrade, so + // the unenforced read path would start handing out rgdp ciphertext. + var resumeState = (DepartmentDataProtectionMigrationKind)policy.ActiveMigrationKind.Value switch + { + DepartmentDataProtectionMigrationKind.Offboarding => DepartmentDataProtectionState.DisableRequested, + DepartmentDataProtectionMigrationKind.Rotation => DepartmentDataProtectionState.Rotating, + DepartmentDataProtectionMigrationKind.CatalogUpgrade => DepartmentDataProtectionState.Encrypting, + _ => DepartmentDataProtectionState.EnrollmentQueued + }; + + var rows = await _policyRepository.TryTransitionStateAsync(departmentId, + DepartmentDataProtectionState.Failed, resumeState, policy.ActiveMigrationKind, + requestingUserId, cancellationToken); + + if (rows == 0) + return DepartmentDataProtectionEnrollmentResult.InvalidState; + + await InvalidateProtectionCacheAsync(departmentId); + Logging.LogInfo($"ADP migration for department {departmentId} re-queued as {resumeState} by {requestingUserId}; resumes from its cursor."); + + return DepartmentDataProtectionEnrollmentResult.Queued; + } + catch (Exception ex) + { + Logging.LogException(ex, $"ADP RetryFailedMigrationAsync failed for department {departmentId}"); + return DepartmentDataProtectionEnrollmentResult.Failed; + } + } + + public async Task AbortActiveMigrationAsync(int departmentId, string requestingUserId, + CancellationToken cancellationToken = default) + { + try + { + var policy = await GetPolicyByDepartmentIdAsync(departmentId, bypassCache: true); + if (policy == null) + return false; + + var state = (DepartmentDataProtectionState)policy.State; + + // Only a state the worker is actually running can be aborted. Enabled, Disabled and + // the scheduled states have no window to stop. + if (state != DepartmentDataProtectionState.ProvisioningKey && + state != DepartmentDataProtectionState.Encrypting && + state != DepartmentDataProtectionState.Verifying && + state != DepartmentDataProtectionState.Decrypting) + return false; + + // The lock goes first. The worker checks it on every heartbeat, so releasing it is + // what actually makes the run stop; flipping the state first would leave a worker + // writing into a department the state says is idle. + var activeLock = await _departmentLockService.GetActiveLockAsync(departmentId, bypassCache: true); + if (activeLock != null) + await _departmentLockService.ReleaseLockAsync(activeLock.DepartmentOperationLockId, + DepartmentOperationLockReleaseKind.Aborted, requestingUserId, cancellationToken); + + var rows = await _policyRepository.TryTransitionStateAsync(departmentId, state, + DepartmentDataProtectionState.Failed, policy.ActiveMigrationKind, requestingUserId, + cancellationToken); + + await InvalidateProtectionCacheAsync(departmentId); + + if (rows > 0) + Logging.LogInfo($"ADP migration for department {departmentId} aborted from {state} by {requestingUserId}; resumable from its cursor."); + + return rows > 0; + } + catch (Exception ex) + { + Logging.LogException(ex, $"ADP AbortActiveMigrationAsync failed for department {departmentId}"); + return false; + } + } + public async Task IncrementPolicyEpochAsync(int departmentId, string updatedByUserId, CancellationToken cancellationToken = default) { var epoch = await _policyRepository.IncrementPolicyEpochAsync(departmentId, updatedByUserId, cancellationToken); diff --git a/Core/Resgrid.Services/DepartmentKeyService.cs b/Core/Resgrid.Services/DepartmentKeyService.cs index 7cbe80890..0e55038b4 100644 --- a/Core/Resgrid.Services/DepartmentKeyService.cs +++ b/Core/Resgrid.Services/DepartmentKeyService.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Resgrid.Framework; @@ -33,6 +34,11 @@ public Task GetActiveKeyAsync(int departmentId) => public Task GetKeyByVersionAsync(int departmentId, int version) => _keyRepository.GetByDepartmentAndVersionAsync(departmentId, version); + public async Task> GetAllVersionsAsync(int departmentId) + { + return await _keyRepository.GetAllVersionsByDepartmentIdAsync(departmentId) ?? new List(); + } + public async Task ProvisionNextKeyVersionAsync(int departmentId, CancellationToken cancellationToken = default) { diff --git a/Core/Resgrid.Services/DocumentsService.cs b/Core/Resgrid.Services/DocumentsService.cs index 08c77aa66..94d7b6d6a 100644 --- a/Core/Resgrid.Services/DocumentsService.cs +++ b/Core/Resgrid.Services/DocumentsService.cs @@ -1,11 +1,13 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; using Resgrid.Providers.Bus; using Resgrid.Repositories.DataRepository; @@ -18,14 +20,17 @@ public class DocumentsService : IDocumentsService private readonly Lazy _protectedWriteService; private readonly IDocumentCategoriesRepository _documentCategoriesRepository; private readonly IEventAggregator _eventAggregator; + private readonly IUnitOfWork _unitOfWork; public DocumentsService(IDocumentRepository documentRepository, IDocumentCategoriesRepository documentCategoriesRepository, - IEventAggregator eventAggregator, Lazy protectedWriteService) + IEventAggregator eventAggregator, Lazy protectedWriteService, + IUnitOfWork unitOfWork) { _protectedWriteService = protectedWriteService; _documentRepository = documentRepository; _documentCategoriesRepository = documentCategoriesRepository; _eventAggregator = eventAggregator; + _unitOfWork = unitOfWork; } public async Task> GetAllDocumentsByDepartmentIdAsync(int departmentId) @@ -74,12 +79,9 @@ public async Task> GetFilteredDocumentsByDepartmentIdAsync(int de if (document != null && document.DocumentId > 0) existing = await _documentRepository.GetByIdAsync(document.DocumentId); - // ADP write safety net (plan 4.2/19.2, catalog v9). - // An UPDATE already has its identity, so it is enveloped BEFORE the save and no plaintext - // version of a cataloged field ever reaches the table - the same split CertificationService - // uses. Only an INSERT has to be persisted first, because the AAD row key IS the identity - // pk and cannot be bound until the database assigns it (plan 4.2/19.2). Fails closed - // either way. + // ADP write safety net (plan 4.2/19.2, catalog v9). An UPDATE already has its identity, so + // it is enveloped BEFORE the save and no plaintext version of a cataloged field ever + // reaches the table - the same split CertificationService uses. Fails closed. var isExistingRow = document != null && document.DocumentId > 0; if (isExistingRow) @@ -88,21 +90,60 @@ public async Task> GetFilteredDocumentsByDepartmentIdAsync(int de document.DepartmentId, document, existing, null, null, workloadCaller: true, cancellationToken); if (!preSaveWrite.Success) throw new InvalidOperationException($"Protected write blocked ({preSaveWrite.Reason}); document {document.DocumentId} was NOT saved."); - } - var saved = await _documentRepository.SaveOrUpdateAsync(document, cancellationToken); + return await _documentRepository.SaveOrUpdateAsync(document, cancellationToken); + } - if (!isExistingRow) + // An INSERT cannot be enveloped first: the AAD row key IS the identity pk, and only the + // database can assign it. So the insert, the encryption and the re-save run inside ONE + // transaction and commit only once the values are enveloped. Without it a broker failure + // left a committed row holding the document's plaintext - including its file bytes - in a + // protected department, which is the exact thing this feature exists to prevent, and + // throwing afterwards did nothing to remove it. + // + // The transaction does span the broker round trip (up to DataProtectionConfig + // .BrokerTimeoutMs). That is deliberate: this is a low-frequency, interactive upload + // holding one new row, and a slow save is recoverable where readable plaintext at rest + // is not. + // + // A caller that ALREADY holds a unit of work is refused rather than served. The rollback + // below is the only thing between a failed protected write and a committed plaintext row, + // and it is not ours to perform on somebody else's transaction: discarding their work + // would be worse than the problem being fixed, and IUnitOfWork exposes no rollback-only + // flag to raise instead - so the caller would go on to commit the plaintext insert this + // method had just made. Refusing is loud and recoverable; committing plaintext is not. + if (_unitOfWork.Connection != null) + throw new InvalidOperationException( + "A new document cannot be created inside a caller-owned transaction: its plaintext insert could not be rolled back independently if the protected write failed."); + + await _unitOfWork.CreateOrGetConnectionAsync(cancellationToken); + + try { + var saved = await _documentRepository.SaveOrUpdateAsync(document, cancellationToken); + var protectedWrite = await _protectedWriteService.Value.PrepareDocumentWriteAsync(saved.DepartmentId, saved, existing, null, null, workloadCaller: true, cancellationToken); if (!protectedWrite.Success) - throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); document {saved.DocumentId} has transient plaintext pending re-encryption."); + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); document {saved.DocumentId} was NOT saved."); + if (protectedWrite.Changed) saved = await _documentRepository.SaveOrUpdateAsync(saved, cancellationToken); + + _unitOfWork.CommitChanges(); + + return saved; } + catch (Exception ex) + { + // Logged here rather than left to the caller: this is the point that knows the insert + // was rolled back, and that no plaintext row survived the failure. + Logging.LogException(ex, $"Document create rolled back for department {document?.DepartmentId}; the protected write did not complete."); - return saved; + _unitOfWork.DiscardChanges(); + + throw; + } } public async Task> GetDistinctCategoriesByDepartmentIdAsync(int departmentId) diff --git a/Core/Resgrid.Services/ProtectedDataGrantService.cs b/Core/Resgrid.Services/ProtectedDataGrantService.cs index e7f0b7918..80b2cbd0c 100644 --- a/Core/Resgrid.Services/ProtectedDataGrantService.cs +++ b/Core/Resgrid.Services/ProtectedDataGrantService.cs @@ -100,7 +100,11 @@ public ProtectedDataGrantIssueResult IssueGrant(ProtectedDataGrantIssueRequest r new Claim(ClientAppClaim, request.ClientApp.ToString(), ClaimValueTypes.Integer32), new Claim(PolicyEpochClaim, request.PolicyEpoch.ToString(), ClaimValueTypes.Integer64), new Claim(MfaAtClaim, ToUnixSeconds(mfaAt).ToString(), ClaimValueTypes.Integer64), - new Claim(AmrClaim, "otp"), + + // amr states honestly how this grant was authenticated. An exempted client produced no + // second factor, so claiming "otp" would put a lie in the audit trail of exactly the + // grants an auditor is most likely to be asking about. + new Claim(AmrClaim, request.StepUpExempt ? "pwd" : "otp"), new Claim(ScopeClaim, string.Join(" ", request.Scopes)) }; @@ -219,6 +223,7 @@ public ProtectedDataGrantValidationOutcome ValidateGrant(string token, int expec PolicyEpoch = policyEpoch, Scopes = scopes, MfaAtUtc = DateTimeOffset.FromUnixTimeSeconds(mfaAtSeconds).UtcDateTime, + StepUpExempt = !string.Equals(principal.FindFirst(AmrClaim)?.Value, "otp", StringComparison.Ordinal), IssuedAtUtc = parsedToken.IssuedAt, ExpiresOnUtc = parsedToken.ValidTo }; diff --git a/Core/Resgrid.Services/ProtectedReadService.cs b/Core/Resgrid.Services/ProtectedReadService.cs index 98ff23f9c..f6b89ae71 100644 --- a/Core/Resgrid.Services/ProtectedReadService.cs +++ b/Core/Resgrid.Services/ProtectedReadService.cs @@ -552,6 +552,35 @@ public async Task ResolveMemberEmergencyContactsForReadAsyn /// /// Callers that can supply the stored row should; this is the floor, not the ceiling. /// + /// + /// The catalog field ids this ONE entity had redacted, read off the entity after a resolve. + /// + /// A batch resolve returns a single result whose RedactedFields is the union across every row + /// it touched, which is the right answer for one record and the wrong one for a list: a field + /// redacted on one contact would be reported as redacted on all of them, including the ones + /// where it is simply empty. Rather than resolve per row — N broker round trips instead of + /// one — the batch runs once and each row's own values are then read back here. + /// + /// Safe because the resolve writes the sentinel INTO the entity, so by this point the entity + /// itself carries the answer. + /// + public static List GetRedactedFieldIds(T entity, + IReadOnlyDictionary Get, Action Set)> accessors) + where T : class + { + var redacted = new List(); + if (entity == null) + return redacted; + + foreach (var accessor in accessors) + { + if (accessor.Value.Get(entity) == ProtectedDataEnvelope.RedactionValue) + redacted.Add(accessor.Key); + } + + return redacted; + } + private static bool ApplySentinelPolicy(T entity, T existing, IReadOnlyDictionary Get, Action Set)> accessors) where T : class diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0144_AddAdpAddonGraceTracking.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0144_AddAdpAddonGraceTracking.cs new file mode 100644 index 000000000..7f062ca23 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0144_AddAdpAddonGraceTracking.cs @@ -0,0 +1,54 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Grace tracking for an ADP addon that has not been paid on time (ADP plan section 17.3). + /// + /// Before this, a lapse only had the provider's own word for when protection should end, and the + /// provider's word is shaped entirely around card billing. An invoiced department on NET45 has + /// not failed a payment at all — no charge was attempted — so a provider-driven end date would + /// start decrypting a paying customer's data while their invoice is still inside its terms. + /// + /// The columns record what was paid for, how the department pays, when the current lapse began, + /// and the resulting floor beneath which offboarding is never scheduled. The floor is computed + /// once per lapse: recomputing it on every retry webhook would let a permanently failing card + /// renew its own grace forever. + /// + [Migration(144)] + public class M0144_AddAdpAddonGraceTracking : Migration + { + private const string Table = "DepartmentDataProtectionPolicies"; + + public override void Up() + { + if (!Schema.Table(Table).Column("AddonPaidThroughOn").Exists()) + Alter.Table(Table).AddColumn("AddonPaidThroughOn").AsDateTime2().Nullable(); + + if (!Schema.Table(Table).Column("AddonBillingMode").Exists()) + Alter.Table(Table).AddColumn("AddonBillingMode").AsInt32().Nullable(); + + if (!Schema.Table(Table).Column("AddonDunningStartedOn").Exists()) + Alter.Table(Table).AddColumn("AddonDunningStartedOn").AsDateTime2().Nullable(); + + if (!Schema.Table(Table).Column("AddonGraceEndsOn").Exists()) + Alter.Table(Table).AddColumn("AddonGraceEndsOn").AsDateTime2().Nullable(); + + if (!Schema.Table(Table).Column("AddonGraceDaysOverride").Exists()) + Alter.Table(Table).AddColumn("AddonGraceDaysOverride").AsInt32().Nullable(); + } + + public override void Down() + { + // Reversible: none of this is member data or key material. Losing it costs the grace + // floor, so a lapse would fall back to the provider's end date — which is why the + // columns exist, but not a reason to refuse a rollback of an unreleased feature. + foreach (var column in new[] { "AddonPaidThroughOn", "AddonBillingMode", "AddonDunningStartedOn", + "AddonGraceEndsOn", "AddonGraceDaysOverride" }) + { + if (Schema.Table(Table).Column(column).Exists()) + Delete.Column(column).FromTable(Table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0145_AddAdpStepUpExemptClients.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0145_AddAdpStepUpExemptClients.cs new file mode 100644 index 000000000..7e7d8a968 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0145_AddAdpStepUpExemptClients.cs @@ -0,0 +1,40 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Per-application step-up exemptions for Advanced Data Protection (plan section 3.3). + /// + /// A department can release named client apps from the second-factor prompt that guards a + /// protected reveal. The column defaults to 0 — nothing exempt, every app prompts — and only an + /// explicit, audited act by the managing member moves it. A department that never opens this + /// setting keeps the stronger behaviour forever. + /// + /// It exists because a dispatcher on a live incident cannot stop to read a code off a phone, and + /// a prompt that lands mid-call is a safety problem rather than a security win. The exemption is + /// per app so a department can keep the prompt on the web site, where the work is administrative, + /// and take it off the dispatch console, where seconds count. + /// + [Migration(145)] + public class M0145_AddAdpStepUpExemptClients : Migration + { + private const string Table = "DepartmentDataProtectionPolicies"; + + public override void Up() + { + // NOT NULL with a zero default: an existing row must come out of this migration + // prompting for everything, and a future insert that forgets the column must too. + if (!Schema.Table(Table).Column("StepUpExemptClients").Exists()) + Alter.Table(Table) + .AddColumn("StepUpExemptClients").AsInt32().NotNullable().WithDefaultValue(0); + } + + public override void Down() + { + // Reversible and safe in the strict direction: losing the column restores the prompt for + // every app rather than removing it. No member data and no key material is involved. + if (Schema.Table(Table).Column("StepUpExemptClients").Exists()) + Delete.Column("StepUpExemptClients").FromTable(Table); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0144_AddAdpAddonGraceTrackingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0144_AddAdpAddonGraceTrackingPg.cs new file mode 100644 index 000000000..bc84864fb --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0144_AddAdpAddonGraceTrackingPg.cs @@ -0,0 +1,54 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Grace tracking for an ADP addon that has not been paid on time (ADP plan section 17.3). + /// + /// Before this, a lapse only had the provider's own word for when protection should end, and the + /// provider's word is shaped entirely around card billing. An invoiced department on NET45 has + /// not failed a payment at all — no charge was attempted — so a provider-driven end date would + /// start decrypting a paying customer's data while their invoice is still inside its terms. + /// + /// The columns record what was paid for, how the department pays, when the current lapse began, + /// and the resulting floor beneath which offboarding is never scheduled. The floor is computed + /// once per lapse: recomputing it on every retry webhook would let a permanently failing card + /// renew its own grace forever. + /// + [Migration(144)] + public class M0144_AddAdpAddonGraceTrackingPg : Migration + { + private const string Table = "departmentdataprotectionpolicies"; + + public override void Up() + { + if (!Schema.Table(Table).Column("addonpaidthroughon").Exists()) + Alter.Table(Table).AddColumn("addonpaidthroughon").AsDateTime2().Nullable(); + + if (!Schema.Table(Table).Column("addonbillingmode").Exists()) + Alter.Table(Table).AddColumn("addonbillingmode").AsInt32().Nullable(); + + if (!Schema.Table(Table).Column("addondunningstartedon").Exists()) + Alter.Table(Table).AddColumn("addondunningstartedon").AsDateTime2().Nullable(); + + if (!Schema.Table(Table).Column("addongraceendson").Exists()) + Alter.Table(Table).AddColumn("addongraceendson").AsDateTime2().Nullable(); + + if (!Schema.Table(Table).Column("addongracedaysoverride").Exists()) + Alter.Table(Table).AddColumn("addongracedaysoverride").AsInt32().Nullable(); + } + + public override void Down() + { + // Reversible: none of this is member data or key material. Losing it costs the grace + // floor, so a lapse would fall back to the provider's end date — which is why the + // columns exist, but not a reason to refuse a rollback of an unreleased feature. + foreach (var column in new[] { "addonpaidthroughon", "addonbillingmode", "addondunningstartedon", + "addongraceendson", "addongracedaysoverride" }) + { + if (Schema.Table(Table).Column(column).Exists()) + Delete.Column(column).FromTable(Table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0145_AddAdpStepUpExemptClientsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0145_AddAdpStepUpExemptClientsPg.cs new file mode 100644 index 000000000..07b415234 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0145_AddAdpStepUpExemptClientsPg.cs @@ -0,0 +1,40 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Per-application step-up exemptions for Advanced Data Protection (plan section 3.3). + /// + /// A department can release named client apps from the second-factor prompt that guards a + /// protected reveal. The column defaults to 0 — nothing exempt, every app prompts — and only an + /// explicit, audited act by the managing member moves it. A department that never opens this + /// setting keeps the stronger behaviour forever. + /// + /// It exists because a dispatcher on a live incident cannot stop to read a code off a phone, and + /// a prompt that lands mid-call is a safety problem rather than a security win. The exemption is + /// per app so a department can keep the prompt on the web site, where the work is administrative, + /// and take it off the dispatch console, where seconds count. + /// + [Migration(145)] + public class M0145_AddAdpStepUpExemptClientsPg : Migration + { + private const string Table = "departmentdataprotectionpolicies"; + + public override void Up() + { + // NOT NULL with a zero default: an existing row must come out of this migration + // prompting for everything, and a future insert that forgets the column must too. + if (!Schema.Table(Table).Column("stepupexemptclients").Exists()) + Alter.Table(Table) + .AddColumn("stepupexemptclients").AsInt32().NotNullable().WithDefaultValue(0); + } + + public override void Down() + { + // Reversible and safe in the strict direction: losing the column restores the prompt for + // every app rather than removing it. No member data and no key material is involved. + if (Schema.Table(Table).Column("stepupexemptclients").Exists()) + Delete.Column("stepupexemptclients").FromTable(Table); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs index bb639d95e..a2b367b68 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs @@ -189,6 +189,59 @@ public async Task CountBinaryResidueAsync(AdpTableBinding binding, int dep return await CountWhereAsync(binding, departmentId, string.Join(" OR ", predicates), cancellationToken); } + public async Task CountSupersededKeyVersionResidueAsync(AdpTableBinding binding, int departmentId, + int targetKeyVersion, CancellationToken cancellationToken = default) + { + if (targetKeyVersion <= 0) + return 0; + + // Both envelope variants carry an ASCII header of "{prefix}{formatVersion}:{keyVersion}:", + // so "on the target version" is a literal prefix match and everything else enveloped is + // residue. Composed from the format constants rather than written out, so a future format + // version cannot leave this silently matching nothing. + var textTargetPrefix = $"{ProtectedDataEnvelope.Prefix}{ProtectedDataEnvelope.CurrentVersion}:{targetKeyVersion}:"; + var binaryTargetPrefix = Encoding.ASCII.GetBytes( + $"{ProtectedDataEnvelope.BinaryPrefix}{ProtectedDataEnvelope.CurrentVersion}:{targetKeyVersion}:"); + + var predicates = new List(); + + foreach (var column in binding.Columns) + { + switch (column.StorageKind) + { + case ProtectedFieldStorageKind.Text: + predicates.Add(TextSupersededPredicate(Ident(column.ColumnName), textTargetPrefix)); + break; + + case ProtectedFieldStorageKind.CompanionColumn: + predicates.Add(TextSupersededPredicate(Ident(column.CompanionColumn), textTargetPrefix)); + break; + + case ProtectedFieldStorageKind.Binary: + { + var envelopeMatch = BinaryPrefixPredicate(Ident(column.ColumnName), BinaryPrefixBytes); + var targetMatch = BinaryPrefixPredicate(Ident(column.ColumnName), binaryTargetPrefix); + predicates.Add($"({Ident(column.ColumnName)} IS NOT NULL AND {envelopeMatch} AND NOT ({targetMatch}))"); + break; + } + } + } + + if (predicates.Count == 0) + return 0; + + return await CountWhereAsync(binding, departmentId, string.Join(" OR ", predicates), cancellationToken); + } + + /// Enveloped text that is not on the target key version. LIKE metacharacters cannot + /// appear in the composed prefix (it is prefix, digits and colons), so it is safe inline. + private static string TextSupersededPredicate(string column, string targetPrefix) => + $"({column} LIKE 'rgdp:%' AND {column} NOT LIKE '{targetPrefix}%')"; + + private string BinaryPrefixPredicate(string column, byte[] prefix) => _isPostgres + ? $"substring({column} from 1 for {prefix.Length}) = '\\x{Convert.ToHexString(prefix).ToLowerInvariant()}'::bytea" + : $"SUBSTRING({column}, 1, {prefix.Length}) = 0x{Convert.ToHexString(prefix)}"; + public async Task CountCompanionResidueAsync(AdpTableBinding binding, int departmentId, bool enveloped, CancellationToken cancellationToken = default) { diff --git a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs index 6a31bc1f3..dc1383c09 100644 --- a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs +++ b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs @@ -72,6 +72,9 @@ private static Dictionary Load(string path) "DataProtection|it|BreadcrumbHome", // Italian UIs use the English "Home". "DataProtection|it|EmailLabel", // "Email" is standard Italian usage. "DataProtection|el|EmailLabel", // Greek UIs use the Latin-script "Email". + "DataProtection|de|AddonStatusLabel", // "Status" is the German word too. + "DataProtection|pl|AddonStatusLabel", // Polish uses "Status" as well. + "DataProtection|sv|AddonStatusLabel", // So does Swedish. // Brand and protocol names carry across every language. "CommunicationTest|de|Push", "CommunicationTest|de|SMS", diff --git a/Tests/Resgrid.Tests/Services/AdpAddonBillingReconciliationTests.cs b/Tests/Resgrid.Tests/Services/AdpAddonBillingReconciliationTests.cs index 7b27cb578..7d2ba0405 100644 --- a/Tests/Resgrid.Tests/Services/AdpAddonBillingReconciliationTests.cs +++ b/Tests/Resgrid.Tests/Services/AdpAddonBillingReconciliationTests.cs @@ -73,7 +73,9 @@ public void SetUp() new Mock().Object, _cacheProvider.Object, new ProtectedFieldCatalog(), - new Mock().Object); + new Mock().Object, + new Mock().Object, + new Mock().Object); } private static AdpAddonBillingEvent Event(AdpAddonBillingEventKind kind, string eventId = "evt-1") => diff --git a/Tests/Resgrid.Tests/Services/AdpAddonGracePeriodTests.cs b/Tests/Resgrid.Tests/Services/AdpAddonGracePeriodTests.cs new file mode 100644 index 000000000..d76650605 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/AdpAddonGracePeriodTests.cs @@ -0,0 +1,292 @@ +using System; +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 +{ + /// + /// What happens to a department's protection when the money is late (ADP plan 17.3). + /// + /// The distinction this exists for: a declined card and an unpaid NET45 invoice both arrive as + /// "not paid", and they are not the same event. The card failed and the provider will retry for + /// days. The invoice has not failed at all — no charge was attempted, it simply is not due yet, + /// and a purchase order can sit in accounts payable for well over a month. Treating the second + /// like the first would decrypt a paying customer's data while their cheque is in the post, and + /// there is no undo for that. + /// + /// So protection outlives the provider's opinion by a grace window sized to how the department + /// pays, and a payment landing inside that window recovers everything with nothing decrypted. + /// + [TestFixture] + public class AdpAddonGracePeriodTests + { + private const int DeptId = 77; + private static readonly DateTime PaidThrough = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc); + + private Mock _policyRepo; + private DepartmentDataProtectionPolicy _policy; + private DepartmentDataProtectionService _service; + + private int _automaticGraceDays; + private int _invoicedGraceDays; + private int _maxGraceDays; + + [SetUp] + public void SetUp() + { + _automaticGraceDays = Resgrid.Config.DataProtectionConfig.AddonAutomaticBillingGraceDays; + _invoicedGraceDays = Resgrid.Config.DataProtectionConfig.AddonInvoicedBillingGraceDays; + _maxGraceDays = Resgrid.Config.DataProtectionConfig.AddonMaxGraceDays; + + _policy = new DepartmentDataProtectionPolicy + { + DepartmentDataProtectionPolicyId = 1, + DepartmentId = DeptId, + State = (int)DepartmentDataProtectionState.Enabled, + AddonPaidThroughOn = PaidThrough + }; + + _policyRepo = new Mock(); + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(() => _policy); + _policyRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentDataProtectionPolicy p, CancellationToken _, bool __) => p); + + _policyRepo.Setup(x => x.TryTransitionStateAsync(DeptId, It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync((int _, DepartmentDataProtectionState from, DepartmentDataProtectionState to, + int? kind, string by, CancellationToken __) => + { + if ((DepartmentDataProtectionState)_policy.State != from) + return 0; + + _policy.State = (int)to; + return 1; + }); + + var cacheProvider = new Mock(); + cacheProvider.Setup(x => x.RetrieveAsync(It.IsAny(), + It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((_, fallback, __) => fallback()); + cacheProvider.Setup(x => x.RemoveAsync(It.IsAny())).ReturnsAsync(true); + + _service = new DepartmentDataProtectionService(_policyRepo.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + cacheProvider.Object, + new ProtectedFieldCatalog(), + new Mock().Object, + new Mock().Object, + new Mock().Object); + } + + [TearDown] + public void TearDown() + { + Resgrid.Config.DataProtectionConfig.AddonAutomaticBillingGraceDays = _automaticGraceDays; + Resgrid.Config.DataProtectionConfig.AddonInvoicedBillingGraceDays = _invoicedGraceDays; + Resgrid.Config.DataProtectionConfig.AddonMaxGraceDays = _maxGraceDays; + } + + private static AdpAddonBillingEvent Event(AdpAddonBillingEventKind kind, string id, DateTime occurredOn) => + new AdpAddonBillingEvent + { + DepartmentId = DeptId, + Kind = kind, + ProviderEventId = id, + ProviderName = "Stripe", + ExternalSubscriptionRef = "sub_1", + OccurredOnUtc = occurredOn + }; + + [Test] + public async Task An_invoiced_department_gets_the_long_grace_and_a_card_gets_the_short_one() + { + _policy.AddonBillingMode = (int)AdpAddonBillingMode.Invoiced; + + var failure = Event(AdpAddonBillingEventKind.PaymentFailed, "evt-1", PaidThrough.AddDays(1)); + await _service.ApplyAddonBillingEventAsync(failure); + + _policy.AddonGraceEndsOn.Should().Be( + PaidThrough.AddDays(Resgrid.Config.DataProtectionConfig.AddonInvoicedBillingGraceDays), + "NET terms mean the invoice is not even due when the cycle ends"); + + // Same department, same event, billed by card instead. + _policy.AddonBillingMode = (int)AdpAddonBillingMode.Automatic; + _policy.AddonGraceEndsOn = null; + _policy.AddonDunningStartedOn = null; + _policy.LastBillingEventId = null; + _policy.LastBillingEventOccurredOn = null; + + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.PaymentFailed, "evt-2", PaidThrough.AddDays(1))); + + _policy.AddonGraceEndsOn.Should().Be( + PaidThrough.AddDays(Resgrid.Config.DataProtectionConfig.AddonAutomaticBillingGraceDays), + "a card that declines is a real failure and the provider is already retrying it"); + } + + [Test] + public async Task A_payment_failure_never_touches_protection() + { + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.PaymentFailed, "evt-1", PaidThrough.AddDays(1))); + + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.Enabled); + _policy.OffboardingEffectiveOn.Should().BeNull("dunning schedules nothing; only exhausting it does"); + } + + [Test] + public async Task Repeated_failures_cannot_push_the_grace_window_forward_forever() + { + // A card that will never work again produces one of these on every retry. If each one + // re-anchored the window, the department would keep protection indefinitely while paying + // nothing — the window has to be fixed when the lapse opens. + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.PaymentFailed, "evt-1", PaidThrough.AddDays(1))); + var firstFloor = _policy.AddonGraceEndsOn; + + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.PaymentFailed, "evt-2", PaidThrough.AddDays(9))); + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.PaymentFailed, "evt-3", PaidThrough.AddDays(13))); + + _policy.AddonGraceEndsOn.Should().Be(firstFloor); + _policy.AddonDunningStartedOn.Should().Be(PaidThrough.AddDays(1), "the lapse began at the first failure"); + } + + [Test] + public async Task Exhausted_dunning_schedules_at_the_grace_floor_not_the_providers_date() + { + _policy.AddonBillingMode = (int)AdpAddonBillingMode.Invoiced; + + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.PaymentFailed, "evt-1", PaidThrough.AddDays(1))); + + // The provider gives up long before an invoiced customer's terms have run out. + var exhausted = Event(AdpAddonBillingEventKind.Cancelled, "evt-2", PaidThrough.AddDays(20)); + exhausted.IsDunningExhausted = true; + exhausted.EffectiveEndUtc = PaidThrough.AddDays(20); + + await _service.ApplyAddonBillingEventAsync(exhausted); + + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.OffboardingScheduled); + _policy.OffboardingEffectiveOn.Should().Be( + PaidThrough.AddDays(Resgrid.Config.DataProtectionConfig.AddonInvoicedBillingGraceDays), + "the provider's patience is not the customer's payment terms"); + _policy.OffboardingSource.Should().Be((int)DepartmentDataProtectionOffboardingSource.DunningExhausted); + } + + [Test] + public async Task A_late_payment_inside_the_window_recovers_everything() + { + _policy.AddonBillingMode = (int)AdpAddonBillingMode.Invoiced; + + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.PaymentFailed, "evt-1", PaidThrough.AddDays(1))); + + var exhausted = Event(AdpAddonBillingEventKind.Cancelled, "evt-2", PaidThrough.AddDays(20)); + exhausted.IsDunningExhausted = true; + await _service.ApplyAddonBillingEventAsync(exhausted); + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.OffboardingScheduled); + + // Day fifty of a NET45 cycle: accounts payable finally pays. + var renewal = Event(AdpAddonBillingEventKind.Renewed, "evt-3", PaidThrough.AddDays(50)); + renewal.PaidThroughUtc = PaidThrough.AddYears(1); + await _service.ApplyAddonBillingEventAsync(renewal); + + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.Enabled); + _policy.OffboardingEffectiveOn.Should().BeNull(); + _policy.AddonGraceEndsOn.Should().BeNull("the lapse is over"); + _policy.AddonDunningStartedOn.Should().BeNull(); + _policy.AddonPaidThroughOn.Should().Be(PaidThrough.AddYears(1)); + } + + [Test] + public async Task A_member_who_cancels_gets_exactly_what_they_paid_for() + { + _policy.AddonBillingMode = (int)AdpAddonBillingMode.Invoiced; + + var cancellation = Event(AdpAddonBillingEventKind.Cancelled, "evt-1", PaidThrough.AddDays(-30)); + cancellation.EffectiveEndUtc = PaidThrough; + + await _service.ApplyAddonBillingEventAsync(cancellation); + + _policy.OffboardingEffectiveOn.Should().Be(PaidThrough, + "they asked to stop - the grace exists for people who have not paid YET, not for people who chose to leave"); + _policy.OffboardingSource.Should().Be((int)DepartmentDataProtectionOffboardingSource.UserCancelled); + } + + [Test] + public async Task A_chargeback_gets_no_grace_at_all() + { + _policy.AddonBillingMode = (int)AdpAddonBillingMode.Invoiced; + + var chargeback = Event(AdpAddonBillingEventKind.Cancelled, "evt-1", PaidThrough.AddDays(5)); + chargeback.IsChargeback = true; + chargeback.EffectiveEndUtc = PaidThrough.AddDays(5); + + await _service.ApplyAddonBillingEventAsync(chargeback); + + _policy.OffboardingEffectiveOn.Should().Be(PaidThrough.AddDays(5), + "a disputed payment is not a slow one"); + _policy.OffboardingSource.Should().Be((int)DepartmentDataProtectionOffboardingSource.Chargeback); + } + + [Test] + public async Task A_support_override_extends_the_window_but_cannot_run_away() + { + _policy.AddonBillingMode = (int)AdpAddonBillingMode.Automatic; + _policy.AddonGraceDaysOverride = 90; + + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.PaymentFailed, "evt-1", PaidThrough.AddDays(1))); + _policy.AddonGraceEndsOn.Should().Be(PaidThrough.AddDays(90)); + + // A typo with an extra digit must not become a permanent free ride. + _policy.AddonGraceEndsOn = null; + _policy.AddonDunningStartedOn = null; + _policy.LastBillingEventId = null; + _policy.LastBillingEventOccurredOn = null; + _policy.AddonGraceDaysOverride = 9000; + + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.PaymentFailed, "evt-2", PaidThrough.AddDays(1))); + + _policy.AddonGraceEndsOn.Should().Be( + PaidThrough.AddDays(Resgrid.Config.DataProtectionConfig.AddonMaxGraceDays)); + } + + [Test] + public async Task A_provider_that_reports_exhaustion_as_a_payment_failure_still_schedules() + { + // Not every provider sends a separate cancellation when it gives up. + var exhausted = Event(AdpAddonBillingEventKind.PaymentFailed, "evt-1", PaidThrough.AddDays(20)); + exhausted.IsDunningExhausted = true; + exhausted.DunningState = "dunning_exhausted"; + + await _service.ApplyAddonBillingEventAsync(exhausted); + + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.OffboardingScheduled); + _policy.OffboardingSource.Should().Be((int)DepartmentDataProtectionOffboardingSource.DunningExhausted); + } + + [Test] + public async Task The_paid_through_date_never_moves_backwards() + { + var renewal = Event(AdpAddonBillingEventKind.Renewed, "evt-1", PaidThrough.AddDays(1)); + renewal.PaidThroughUtc = PaidThrough.AddYears(1); + await _service.ApplyAddonBillingEventAsync(renewal); + + // A webhook from an older cycle turning up late must not shorten what the department has + // already been told it is paid up to. + var late = Event(AdpAddonBillingEventKind.Renewed, "evt-2", PaidThrough.AddDays(2)); + late.PaidThroughUtc = PaidThrough; + await _service.ApplyAddonBillingEventAsync(late); + + _policy.AddonPaidThroughOn.Should().Be(PaidThrough.AddYears(1)); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/AdpMigrationOperatorControlsTests.cs b/Tests/Resgrid.Tests/Services/AdpMigrationOperatorControlsTests.cs new file mode 100644 index 000000000..a9b1ead6a --- /dev/null +++ b/Tests/Resgrid.Tests/Services/AdpMigrationOperatorControlsTests.cs @@ -0,0 +1,232 @@ +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 +{ + /// + /// The two operator controls behind the BackOffice ADP dashboard (plan 7.4): put a failed run + /// back on the queue, and stop one that is going wrong. + /// + /// Both are deliberately narrow. Neither undoes work: every row a run has already processed + /// stays exactly as it is, which is what makes stopping safe at any point and what makes + /// resuming from a cursor meaningful. Neither touches a key, and neither can be reached for a + /// department that is not in a state where it makes sense. + /// + [TestFixture] + public class AdpMigrationOperatorControlsTests + { + private const int DeptId = 31; + private const string Operator = "ops@resgrid.com"; + + private Mock _policyRepo; + private Mock _lockService; + private DepartmentDataProtectionPolicy _policy; + private DepartmentDataProtectionService _service; + + [SetUp] + public void SetUp() + { + _policy = new DepartmentDataProtectionPolicy + { + DepartmentDataProtectionPolicyId = 1, + DepartmentId = DeptId, + State = (int)DepartmentDataProtectionState.Failed, + ActiveMigrationKind = (int)DepartmentDataProtectionMigrationKind.Enrollment + }; + + _policyRepo = new Mock(); + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(() => _policy); + _policyRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentDataProtectionPolicy p, CancellationToken _, bool __) => p); + + _policyRepo.Setup(x => x.TryTransitionStateAsync(DeptId, It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync((int _, DepartmentDataProtectionState from, DepartmentDataProtectionState to, + int? kind, string by, CancellationToken __) => + { + if ((DepartmentDataProtectionState)_policy.State != from) + return 0; + + _policy.State = (int)to; + return 1; + }); + + _lockService = new Mock(); + _lockService.Setup(x => x.GetActiveLockAsync(DeptId, It.IsAny())) + .ReturnsAsync((DepartmentOperationLock)null); + _lockService.Setup(x => x.ReleaseLockAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var cacheProvider = new Mock(); + cacheProvider.Setup(x => x.RetrieveAsync(It.IsAny(), + It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((_, fallback, __) => fallback()); + cacheProvider.Setup(x => x.RemoveAsync(It.IsAny())).ReturnsAsync(true); + + _service = new DepartmentDataProtectionService(_policyRepo.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + cacheProvider.Object, + new ProtectedFieldCatalog(), + new Mock().Object, + _lockService.Object, + new Mock().Object); + } + + [Test] + public async Task A_failed_enrollment_resumes_as_an_enrollment() + { + var result = await _service.RetryFailedMigrationAsync(DeptId, Operator); + + result.Should().Be(DepartmentDataProtectionEnrollmentResult.Queued); + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.EnrollmentQueued); + } + + [Test] + public async Task A_failed_offboarding_resumes_as_an_offboarding() + { + // Encrypting and decrypting from the same cursor are opposite operations. Resuming an + // offboarding into the enrollment queue would re-encrypt a department on its way out. + _policy.ActiveMigrationKind = (int)DepartmentDataProtectionMigrationKind.Offboarding; + + await _service.RetryFailedMigrationAsync(DeptId, Operator); + + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.DisableRequested); + } + + [Test] + public async Task A_failed_rotation_resumes_as_a_rotation() + { + _policy.ActiveMigrationKind = (int)DepartmentDataProtectionMigrationKind.Rotation; + + await _service.RetryFailedMigrationAsync(DeptId, Operator); + + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.Rotating); + } + + [Test] + public async Task A_failed_catalog_upgrade_resumes_as_a_catalog_upgrade() + { + // EnrollmentQueued would be worse than a wasted pass. The worker's enrollment path + // rewrites ActiveMigrationKind to Enrollment on its first transition, and an Encrypting + // department only enforces protection while its kind still reads CatalogUpgrade - so the + // resumed run would hand rgdp ciphertext to clients through the unenforced read path. + _policy.ActiveMigrationKind = (int)DepartmentDataProtectionMigrationKind.CatalogUpgrade; + + var result = await _service.RetryFailedMigrationAsync(DeptId, Operator); + + result.Should().Be(DepartmentDataProtectionEnrollmentResult.Queued); + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.Encrypting); + _policyRepo.Verify(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.Failed, + DepartmentDataProtectionState.Encrypting, + (int)DepartmentDataProtectionMigrationKind.CatalogUpgrade, Operator, It.IsAny()), + Times.Once, "the kind is what keeps enforcement on and the field scope narrow"); + } + + [Test] + public async Task A_failed_run_with_no_recorded_kind_is_not_resumed_at_all() + { + _policy.ActiveMigrationKind = null; + + var result = await _service.RetryFailedMigrationAsync(DeptId, Operator); + + result.Should().Be(DepartmentDataProtectionEnrollmentResult.InvalidState, + "guessing the direction of a resumed run is worse than refusing to resume it"); + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.Failed); + } + + [Test] + public async Task Only_a_failed_department_can_be_retried() + { + _policy.State = (int)DepartmentDataProtectionState.Encrypting; + + var result = await _service.RetryFailedMigrationAsync(DeptId, Operator); + + result.Should().Be(DepartmentDataProtectionEnrollmentResult.InvalidState); + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.Encrypting, + "a running migration must not be shoved back into the queue underneath its own worker"); + } + + [Test] + public async Task Aborting_releases_the_lock_before_it_moves_the_state() + { + _policy.State = (int)DepartmentDataProtectionState.Encrypting; + + var order = new List(); + var held = new DepartmentOperationLock { DepartmentOperationLockId = 55, DepartmentId = DeptId }; + + _lockService.Setup(x => x.GetActiveLockAsync(DeptId, It.IsAny())).ReturnsAsync(held); + _lockService.Setup(x => x.ReleaseLockAsync(55, DepartmentOperationLockReleaseKind.Aborted, + Operator, It.IsAny())) + .ReturnsAsync(true) + .Callback(() => order.Add("release")); + + _policyRepo.Setup(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.Encrypting, + DepartmentDataProtectionState.Failed, It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(1) + .Callback(() => + { + order.Add("state"); + _policy.State = (int)DepartmentDataProtectionState.Failed; + }); + + var aborted = await _service.AbortActiveMigrationAsync(DeptId, Operator); + + aborted.Should().BeTrue(); + + // The worker checks the lock on every heartbeat, so releasing it is what actually stops + // the run. Flipping the state first would leave a worker writing into a department the + // state already says is idle. + order.Should().Equal(new[] { "release", "state" }); + } + + [Test] + public async Task Aborting_leaves_the_run_retryable() + { + _policy.State = (int)DepartmentDataProtectionState.Decrypting; + _policy.ActiveMigrationKind = (int)DepartmentDataProtectionMigrationKind.Offboarding; + + await _service.AbortActiveMigrationAsync(DeptId, Operator); + + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.Failed); + + // Failed with the kind preserved is exactly the state Retry understands. + var retried = await _service.RetryFailedMigrationAsync(DeptId, Operator); + retried.Should().Be(DepartmentDataProtectionEnrollmentResult.Queued); + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.DisableRequested); + } + + [TestCase(DepartmentDataProtectionState.Enabled)] + [TestCase(DepartmentDataProtectionState.Disabled)] + [TestCase(DepartmentDataProtectionState.OffboardingScheduled)] + [TestCase(DepartmentDataProtectionState.Failed)] + public async Task A_department_with_no_window_running_cannot_be_aborted(DepartmentDataProtectionState state) + { + _policy.State = (int)state; + + var aborted = await _service.AbortActiveMigrationAsync(DeptId, Operator); + + aborted.Should().BeFalse(); + ((DepartmentDataProtectionState)_policy.State).Should().Be(state); + _lockService.Verify(x => x.ReleaseLockAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/AdpStepUpExemptionTests.cs b/Tests/Resgrid.Tests/Services/AdpStepUpExemptionTests.cs new file mode 100644 index 000000000..4a3ee0f4f --- /dev/null +++ b/Tests/Resgrid.Tests/Services/AdpStepUpExemptionTests.cs @@ -0,0 +1,222 @@ +using System; +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 +{ + /// + /// Per-application step-up exemptions (ADP plan 3.3). + /// + /// A department can release named apps from the second-factor prompt that guards a protected + /// reveal, because a dispatcher on a live incident cannot stop to read a code off a phone and a + /// prompt that lands mid-call is a safety problem rather than a security win. + /// + /// Everything here is about the direction of the default. Nothing is exempt until someone + /// deliberately exempts it, an unknown answer means "prompt", and a client that cannot identify + /// itself never inherits somebody else's exemption. + /// + [TestFixture] + public class AdpStepUpExemptionTests + { + private const int DeptId = 88; + private const string ManagingUserId = "the-owner"; + private const string OtherAdminUserId = "an-admin"; + + private Mock _policyRepo; + private Mock _departmentsService; + private DepartmentDataProtectionPolicy _policy; + private DepartmentDataProtectionService _service; + + [SetUp] + public void SetUp() + { + _policy = new DepartmentDataProtectionPolicy + { + DepartmentDataProtectionPolicyId = 1, + DepartmentId = DeptId, + State = (int)DepartmentDataProtectionState.Enabled, + PolicyEpoch = 4 + }; + + _policyRepo = new Mock(); + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(() => _policy); + _policyRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentDataProtectionPolicy p, CancellationToken _, bool __) => p); + _policyRepo.Setup(x => x.IncrementPolicyEpochAsync(DeptId, It.IsAny(), It.IsAny())) + .ReturnsAsync(() => ++_policy.PolicyEpoch); + + _departmentsService = new Mock(); + _departmentsService.Setup(x => x.GetDepartmentByIdAsync(DeptId, It.IsAny())) + .ReturnsAsync(new Department { DepartmentId = DeptId, ManagingUserId = ManagingUserId }); + + var cacheProvider = new Mock(); + cacheProvider.Setup(x => x.RetrieveAsync(It.IsAny(), + It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((_, fallback, __) => fallback()); + cacheProvider.Setup(x => x.RemoveAsync(It.IsAny())).ReturnsAsync(true); + + _service = new DepartmentDataProtectionService(_policyRepo.Object, + new Mock().Object, + _departmentsService.Object, + new Mock().Object, + new Mock().Object, + cacheProvider.Object, + new ProtectedFieldCatalog(), + new Mock().Object, + new Mock().Object, + new Mock().Object); + } + + [TestCase(UserSessionClientApplication.Web)] + [TestCase(UserSessionClientApplication.Dispatch)] + [TestCase(UserSessionClientApplication.Responder)] + [TestCase(UserSessionClientApplication.Unit)] + [TestCase(UserSessionClientApplication.Command)] + [TestCase(UserSessionClientApplication.Api)] + public async Task Every_app_prompts_until_a_department_says_otherwise(UserSessionClientApplication client) + { + (await _service.IsStepUpRequiredForClientAsync(DeptId, client)).Should().BeTrue( + "a department that has never opened this setting keeps the stronger behaviour"); + } + + [Test] + public async Task An_exemption_applies_only_to_the_app_it_was_granted_for() + { + _policy.StepUpExemptClients = (int)AdpStepUpExemptClients.Dispatch; + + (await _service.IsStepUpRequiredForClientAsync(DeptId, UserSessionClientApplication.Dispatch)) + .Should().BeFalse("the dispatch console is what this exists for"); + + (await _service.IsStepUpRequiredForClientAsync(DeptId, UserSessionClientApplication.Web)) + .Should().BeTrue("someone at a desk on the web site is not under the same time pressure"); + + (await _service.IsStepUpRequiredForClientAsync(DeptId, UserSessionClientApplication.Responder)) + .Should().BeTrue(); + } + + [TestCase(UserSessionClientApplication.BigBoard)] + [TestCase(UserSessionClientApplication.Mcp)] + [TestCase(UserSessionClientApplication.UnknownLegacy)] + public async Task Some_clients_can_never_be_exempted(UserSessionClientApplication client) + { + // Every bit set, including ones that map to nothing. + _policy.StepUpExemptClients = int.MaxValue; + + (await _service.IsStepUpRequiredForClientAsync(DeptId, client)).Should().BeTrue( + "BigBoard has nobody to prompt and no business seeing protected values, MCP is automated, " + + "and a client that cannot identify itself must not inherit somebody else's exemption"); + } + + [TestCase(UserSessionClientApplication.BigBoard)] + [TestCase(UserSessionClientApplication.Mcp)] + [TestCase(UserSessionClientApplication.UnknownLegacy)] + public void The_allow_list_refuses_these_clients_on_its_own(UserSessionClientApplication client) + { + // Deliberately WITHOUT Sanitize. Two independent things refuse a non-exemptable client - + // the stored value is sanitized on the way out, and IsExempt has its own allow-list - and + // the test above only proves the first. Removing either would be a silent hole, so each + // is pinned separately. + ((AdpStepUpExemptClients)int.MaxValue).IsExempt(client).Should().BeFalse(); + } + + [Test] + public async Task A_stored_value_carrying_meaningless_bits_cannot_smuggle_an_exemption() + { + _policy.StepUpExemptClients = int.MaxValue; + + var exemptions = await _service.GetStepUpExemptClientsAsync(DeptId); + + exemptions.Should().Be(AdpStepUpExemptClients.Web | AdpStepUpExemptClients.Responder | + AdpStepUpExemptClients.Unit | AdpStepUpExemptClients.Dispatch | + AdpStepUpExemptClients.Command | AdpStepUpExemptClients.Api); + } + + [Test] + public async Task A_lookup_that_fails_prompts_rather_than_guessing() + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ThrowsAsync(new InvalidOperationException("db")); + + (await _service.IsStepUpRequiredForClientAsync(DeptId, UserSessionClientApplication.Dispatch)) + .Should().BeTrue("the safe answer to 'I am not sure' is the prompt"); + } + + [Test] + public async Task A_department_with_no_policy_prompts() + { + _policy = null; + + (await _service.IsStepUpRequiredForClientAsync(DeptId, UserSessionClientApplication.Dispatch)) + .Should().BeTrue(); + } + + [Test] + public async Task Only_the_managing_member_can_change_it() + { + var result = await _service.SetStepUpExemptClientsAsync(DeptId, AdpStepUpExemptClients.Dispatch, + OtherAdminUserId); + + result.Should().NotBe(DepartmentDataProtectionEnrollmentResult.Queued); + _policy.StepUpExemptClients.Should().Be(0, + "weakening a protection control is not something any administrator can do quietly"); + } + + [Test] + public async Task Turning_the_prompt_off_bumps_the_policy_epoch() + { + var before = _policy.PolicyEpoch; + + await _service.SetStepUpExemptClientsAsync(DeptId, AdpStepUpExemptClients.Dispatch, ManagingUserId); + + _policy.StepUpExemptClients.Should().Be((int)AdpStepUpExemptClients.Dispatch); + _policy.PolicyEpoch.Should().BeGreaterThan(before); + } + + [Test] + public async Task Turning_the_prompt_back_ON_also_bumps_the_epoch() + { + // The one that actually matters. Without the bump, grants minted while the app was exempt + // keep working until they expire - so re-enabling the prompt would not bite for the rest + // of the window, which is precisely when someone re-enabling it is worried. + _policy.StepUpExemptClients = (int)AdpStepUpExemptClients.Dispatch; + var before = _policy.PolicyEpoch; + + await _service.SetStepUpExemptClientsAsync(DeptId, AdpStepUpExemptClients.None, ManagingUserId); + + _policy.StepUpExemptClients.Should().Be(0); + _policy.PolicyEpoch.Should().BeGreaterThan(before); + } + + [Test] + public async Task Saving_the_same_value_changes_nothing() + { + _policy.StepUpExemptClients = (int)AdpStepUpExemptClients.Dispatch; + var before = _policy.PolicyEpoch; + + var result = await _service.SetStepUpExemptClientsAsync(DeptId, AdpStepUpExemptClients.Dispatch, ManagingUserId); + + result.Should().Be(DepartmentDataProtectionEnrollmentResult.Queued); + _policy.PolicyEpoch.Should().Be(before, + "a no-op save must not revoke every outstanding grant in the department"); + } + + [Test] + public async Task Unmappable_bits_are_stripped_before_they_are_stored() + { + await _service.SetStepUpExemptClientsAsync(DeptId, (AdpStepUpExemptClients)int.MaxValue, ManagingUserId); + + ((AdpStepUpExemptClients)_policy.StepUpExemptClients).Should().Be( + AdpStepUpExemptClients.Web | AdpStepUpExemptClients.Responder | AdpStepUpExemptClients.Unit | + AdpStepUpExemptClients.Dispatch | AdpStepUpExemptClients.Command | AdpStepUpExemptClients.Api, + "a stored value must never carry meaning nothing reads"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs b/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs index d50a2447f..1ae382b82 100644 --- a/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs +++ b/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs @@ -177,6 +177,109 @@ public async Task Fresh_run_after_a_reprovision_validates_old_version_envelopes_ "old-version envelopes are counted already-protected, never re-encrypted"); } + /// + /// Rotation rides the encryption path, and the difference between the two is one branch: an + /// envelope on an older version is re-encrypted rather than counted already-protected. The + /// test that matters is that the VALUE survives it — a rotation that silently changed data + /// would be indistinguishable from one that worked until someone read a record back. + /// + [Test] + public async Task Rotation_re_encrypts_every_envelope_under_the_new_key_and_preserves_the_values() + { + await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + var beforeRotation = (string)_bulk.Table("Calls")[0]["Name"]; + + var v2 = await ProvisionVersionTwoAsync(); + + var context = Context(DepartmentDataProtectionMigrationKind.Rotation); + context.TargetKeyVersion = 2; + var result = await _engine.RunEncryptionNightAsync(context, CancellationToken.None); + + result.Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + + var rotated = (string)_bulk.Table("Calls")[0]["Name"]; + rotated.Should().NotBe(beforeRotation, "the envelope was rewritten"); + rotated.Should().StartWith("rgdp:1:2:", "and rewritten under the new key version"); + + // The whole point: same plaintext, different key. + _crypto.DecryptText(v2, rotated, DeptId, "calls.name", "1").Should().Be("Structure Fire"); + } + + [Test] + public async Task Rotation_leaves_a_value_already_on_the_target_version_untouched() + { + // Resumability: a rotation that stopped mid-table and restarted must not rewrite what the + // first pass already moved, or every retry would churn the whole corpus again. + await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + await ProvisionVersionTwoAsync(); + + var context = Context(DepartmentDataProtectionMigrationKind.Rotation); + context.TargetKeyVersion = 2; + await _engine.RunEncryptionNightAsync(context, CancellationToken.None); + var afterFirstRotation = _bulk.Snapshot("Calls"); + + _migrations.Rows.Clear(); + var second = await _engine.RunEncryptionNightAsync(context, CancellationToken.None); + + second.Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + _bulk.Snapshot("Calls").Should().BeEquivalentTo(afterFirstRotation, + "values already on the target version are counted already-protected, not re-keyed again"); + } + + [Test] + public async Task Rotation_verification_fails_while_anything_is_still_on_the_old_version() + { + // This gate is what stands between a half-rotated department and a retired key version + // that would make those rows permanently unreadable. + await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + await ProvisionVersionTwoAsync(); + + var context = Context(DepartmentDataProtectionMigrationKind.Rotation); + context.TargetKeyVersion = 2; + + (await _engine.VerifyAsync(context, CancellationToken.None)).Should().BeFalse( + "nothing has been re-keyed yet, so every row still references v1"); + + await _engine.RunEncryptionNightAsync(context, CancellationToken.None); + + (await _engine.VerifyAsync(context, CancellationToken.None)).Should().BeTrue( + "after the sweep no envelope references a superseded version"); + } + + [Test] + public async Task Rotation_still_halts_on_an_envelope_it_cannot_open() + { + // Re-keying must not become a way to launder a foreign envelope into this department's + // current key: the decrypt that produces the plaintext is the same one that validates it. + await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + await ProvisionVersionTwoAsync(); + + _bulk.Table("Calls")[0]["Name"] = "rgdp:1:1:" + Convert.ToBase64String(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }); + + var context = Context(DepartmentDataProtectionMigrationKind.Rotation); + context.TargetKeyVersion = 2; + var result = await _engine.RunEncryptionNightAsync(context, CancellationToken.None); + + result.Outcome.Should().Be(AdpMigrationNightOutcome.Failed); + } + + private async Task ProvisionVersionTwoAsync() + { + var wrapped = await _keyProvider.GenerateWrappedDataKeyAsync(DeptId); + var keyRow = new DepartmentDataProtectionKey + { + DepartmentId = DeptId, + Version = 2, + WrappedKey = wrapped.WrappedKeyBase64, + Status = (int)DepartmentDataProtectionKeyStatus.Active + }; + + _keyService.Setup(x => x.GetKeyByVersionAsync(DeptId, 2)).ReturnsAsync(keyRow); + _keyService.Setup(x => x.GetActiveKeyAsync(DeptId)).ReturnsAsync(keyRow); + + return await _keyProvider.UnwrapDataKeyAsync(DeptId, wrapped.WrappedKeyBase64); + } + [Test] public async Task Envelope_referencing_an_unknown_key_version_halts_the_run() { @@ -366,6 +469,47 @@ public Task CountTextResidueAsync(AdpTableBinding binding, int departmentI return Task.FromResult((long)count); } + public Task CountSupersededKeyVersionResidueAsync(AdpTableBinding binding, int departmentId, + int targetKeyVersion, CancellationToken cancellationToken = default) + { + if (!_tables.TryGetValue(binding.TableName, out var table)) + return Task.FromResult(0L); + + var textPrefix = $"rgdp:{ProtectedDataEnvelope.CurrentVersion}:{targetKeyVersion}:"; + var binaryPrefix = System.Text.Encoding.ASCII.GetBytes( + $"rgdpb:{ProtectedDataEnvelope.CurrentVersion}:{targetKeyVersion}:"); + + bool TextIsStale(string value) => + !string.IsNullOrEmpty(value) + && value.StartsWith("rgdp:", StringComparison.Ordinal) + && !value.StartsWith(textPrefix, StringComparison.Ordinal); + + bool BinaryIsStale(byte[] value) + { + if (value == null || value.Length < 6) + return false; + + var isEnvelope = System.Text.Encoding.ASCII.GetString(value, 0, 6) == "rgdpb:"; + if (!isEnvelope) + return false; + + if (value.Length < binaryPrefix.Length) + return true; + + return !value.Take(binaryPrefix.Length).SequenceEqual(binaryPrefix); + } + + var count = table.Rows.Count(r => binding.Columns.Any(c => c.StorageKind switch + { + ProtectedFieldStorageKind.Text => TextIsStale(r.TryGetValue(c.ColumnName, out var t) ? t as string : null), + ProtectedFieldStorageKind.CompanionColumn => TextIsStale(r.TryGetValue(c.CompanionColumn, out var cc) ? cc as string : null), + ProtectedFieldStorageKind.Binary => BinaryIsStale(r.TryGetValue(c.ColumnName, out var b) ? b as byte[] : null), + _ => false + })); + + return Task.FromResult((long)count); + } + public Task CountBinaryResidueAsync(AdpTableBinding binding, int departmentId, bool enveloped, CancellationToken cancellationToken = default) { if (!_tables.TryGetValue(binding.TableName, out var table)) diff --git a/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs b/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs index 1140e6268..4368ec808 100644 --- a/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs @@ -63,7 +63,9 @@ public void SetUp() _service = new DepartmentDataProtectionService(_policyRepo.Object, _egressRepo.Object, _departmentsService.Object, _featureToggleService.Object, _subscriptionsService.Object, - _cacheProvider.Object, new ProtectedFieldCatalog(), _migrationRepo.Object); + _cacheProvider.Object, new ProtectedFieldCatalog(), _migrationRepo.Object, + new Mock().Object, + new Mock().Object); } #region QueueEnrollment gates diff --git a/Tests/Resgrid.Tests/Services/DocumentProtectedWriteTransactionTests.cs b/Tests/Resgrid.Tests/Services/DocumentProtectedWriteTransactionTests.cs new file mode 100644 index 000000000..fa0f9781a --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DocumentProtectedWriteTransactionTests.cs @@ -0,0 +1,158 @@ +using System; +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.Repositories.Queries; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// A new document cannot be enveloped before it is inserted — the AAD row key IS the identity pk, + /// and only the database can assign it. That leaves a window where the row holds the document's + /// plaintext, file bytes included, and a broker failure used to commit that window permanently: + /// the method threw, but the row stayed. + /// + /// So the insert, the encryption and the re-save share one transaction that commits only once the + /// values are enveloped. An UPDATE has none of this problem — it already has its identity, so it + /// is enveloped before the save and never writes plaintext at all. + /// + [TestFixture] + public class DocumentProtectedWriteTransactionTests + { + private const int DeptId = 4; + + private Mock _repo; + private Mock _protectedWriteService; + private Mock _unitOfWork; + private DocumentsService _service; + + [SetUp] + public void SetUp() + { + _repo = new Mock(); + _repo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Document d, CancellationToken _, bool __) => + { + if (d.DocumentId == 0) + d.DocumentId = 99; // the database assigning the identity + + return d; + }); + + _protectedWriteService = new Mock(); + _protectedWriteService.Setup(x => x.PrepareDocumentWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed(isProtected: true, changed: true)); + + _unitOfWork = new Mock(); + _unitOfWork.SetupGet(x => x.Connection).Returns((System.Data.Common.DbConnection)null); + + _service = new DocumentsService(_repo.Object, new Mock().Object, + new Mock().Object, + new Lazy(() => _protectedWriteService.Object), + _unitOfWork.Object); + } + + private static Document New() => new Document { DepartmentId = DeptId, Name = "SOP", Data = new byte[] { 1, 2, 3 } }; + + [Test] + public async Task A_new_document_commits_only_after_the_values_are_enveloped() + { + var order = new System.Collections.Generic.List(); + + _unitOfWork.Setup(x => x.CreateOrGetConnectionAsync(It.IsAny())) + .ReturnsAsync((System.Data.Common.DbConnection)null) + .Callback(() => order.Add("begin")); + _repo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Document d, CancellationToken _, bool __) => d) + .Callback(() => order.Add("save")); + _protectedWriteService.Setup(x => x.PrepareDocumentWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed(isProtected: true, changed: true)) + .Callback(() => order.Add("protect")); + _unitOfWork.Setup(x => x.CommitChanges()).Callback(() => order.Add("commit")); + + await _service.SaveDocumentAsync(New()); + + order.Should().Equal(new[] { "begin", "save", "protect", "save", "commit" }); + } + + [Test] + public async Task A_blocked_broker_rolls_the_insert_back() + { + _protectedWriteService.Setup(x => x.PrepareDocumentWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Blocked("broker_unavailable")); + + Func save = () => _service.SaveDocumentAsync(New()); + + await save.Should().ThrowAsync().WithMessage("*broker_unavailable*"); + + _unitOfWork.Verify(x => x.DiscardChanges(), Times.Once, + "the inserted row holds the document's plaintext until the envelopes land"); + _unitOfWork.Verify(x => x.CommitChanges(), Times.Never); + } + + [Test] + public async Task A_broker_that_throws_rolls_the_insert_back_too() + { + _protectedWriteService.Setup(x => x.PrepareDocumentWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ThrowsAsync(new TimeoutException("broker")); + + Func save = () => _service.SaveDocumentAsync(New()); + + await save.Should().ThrowAsync(); + + _unitOfWork.Verify(x => x.DiscardChanges(), Times.Once); + _unitOfWork.Verify(x => x.CommitChanges(), Times.Never); + } + + [Test] + public async Task An_existing_document_is_enveloped_before_the_save_and_needs_no_transaction() + { + var existing = new Document { DocumentId = 7, DepartmentId = DeptId, Name = "SOP" }; + _repo.Setup(x => x.GetByIdAsync(7)).ReturnsAsync(existing); + + await _service.SaveDocumentAsync(new Document { DocumentId = 7, DepartmentId = DeptId, Name = "SOP v2" }); + + // Nothing plaintext ever reaches the table on this path, so there is nothing to roll back. + _unitOfWork.Verify(x => x.CreateOrGetConnectionAsync(It.IsAny()), Times.Never); + _unitOfWork.Verify(x => x.CommitChanges(), Times.Never); + _repo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once, "an update is saved exactly once — already enveloped"); + } + + [Test] + public async Task A_new_document_is_refused_inside_a_caller_owned_transaction() + { + // Committing someone else's in-flight unit of work here — or discarding it — would be + // worse than the problem this transaction solves, and IUnitOfWork has no rollback-only + // flag to raise instead. Serving the caller anyway would insert the plaintext row and + // then leave the caller free to commit it, so the save is refused before the insert. + _unitOfWork.SetupGet(x => x.Connection).Returns(Mock.Of()); + + var act = async () => await _service.SaveDocumentAsync(New()); + + await act.Should().ThrowAsync() + .WithMessage("*caller-owned transaction*"); + + _repo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never, "nothing plaintext may be inserted on a path that cannot roll it back"); + _unitOfWork.Verify(x => x.CreateOrGetConnectionAsync(It.IsAny()), Times.Never); + _unitOfWork.Verify(x => x.CommitChanges(), Times.Never); + _unitOfWork.Verify(x => x.DiscardChanges(), Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedRedactedFieldIdsTests.cs b/Tests/Resgrid.Tests/Services/ProtectedRedactedFieldIdsTests.cs new file mode 100644 index 000000000..2023e9088 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedRedactedFieldIdsTests.cs @@ -0,0 +1,106 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Per-row redaction reporting for LIST endpoints. + /// + /// A batch resolve returns one result whose RedactedFields is the union across every row it + /// touched. That is the right answer for a single record and the wrong one for a list: a field + /// redacted on one contact would be reported as redacted on all of them, including rows where + /// the value is simply empty. + /// + /// The alternative — resolving each row separately — is N broker round trips instead of one, on + /// the endpoint most likely to return hundreds of rows. So the batch runs once and each row's + /// own values are read back afterwards, which is what this pins. + /// + [TestFixture] + public class ProtectedRedactedFieldIdsTests + { + private static Contact Contact(string firstName = null, string lastName = null, string email = null) => + new Contact { FirstName = firstName, LastName = lastName, Email = email }; + + [Test] + public void Reports_only_the_fields_this_row_actually_had_redacted() + { + var contact = Contact( + firstName: ProtectedDataEnvelope.RedactionValue, + lastName: "Doe", + email: ProtectedDataEnvelope.RedactionValue); + + var redacted = ProtectedReadService.GetRedactedFieldIds(contact, ProtectedReadService.ContactFieldAccessors); + + redacted.Should().BeEquivalentTo(new[] { "contacts.firstname", "contacts.email" }); + } + + [Test] + public void A_row_with_nothing_redacted_reports_nothing() + { + var redacted = ProtectedReadService.GetRedactedFieldIds( + Contact(firstName: "Jamie", lastName: "Doe", email: "jamie@example.com"), + ProtectedReadService.ContactFieldAccessors); + + redacted.Should().BeEmpty(); + } + + [Test] + public void An_empty_value_is_not_a_redacted_one() + { + // The distinction the union got wrong: a contact with no email has nothing withheld, and + // saying otherwise would put a lock icon on a field that is simply blank. + var redacted = ProtectedReadService.GetRedactedFieldIds( + Contact(firstName: "Jamie", lastName: "Doe", email: string.Empty), + ProtectedReadService.ContactFieldAccessors); + + redacted.Should().BeEmpty(); + } + + [Test] + public void Two_rows_in_one_batch_report_independently() + { + // The actual list case. One contact's withheld email must not mark the other's. + var withheld = Contact(firstName: "Jamie", email: ProtectedDataEnvelope.RedactionValue); + var plain = Contact(firstName: "Alex", email: "alex@example.com"); + + ProtectedReadService.GetRedactedFieldIds(withheld, ProtectedReadService.ContactFieldAccessors) + .Should().Contain("contacts.email"); + ProtectedReadService.GetRedactedFieldIds(plain, ProtectedReadService.ContactFieldAccessors) + .Should().NotContain("contacts.email"); + } + + [Test] + public void A_value_that_merely_resembles_the_sentinel_is_not_redacted() + { + // Ordinal equality, not a contains or a case-insensitive match: a member is allowed to + // write "redacted" in a field and have it shown back to them. + var redacted = ProtectedReadService.GetRedactedFieldIds( + Contact(firstName: "redacted", lastName: "REDACTED ", email: "REDACTEDX"), + ProtectedReadService.ContactFieldAccessors); + + redacted.Should().BeEmpty(); + } + + [Test] + public void A_null_entity_reports_nothing_rather_than_throwing() + { + ProtectedReadService.GetRedactedFieldIds((Contact)null, ProtectedReadService.ContactFieldAccessors) + .Should().BeEmpty(); + } + + [Test] + public void Every_reported_id_is_a_real_catalog_field() + { + // The ids go to clients, which match them against their own catalog constants. One that + // does not exist server-side would never match and the field would render raw. + var contact = Contact(firstName: ProtectedDataEnvelope.RedactionValue); + + var redacted = ProtectedReadService.GetRedactedFieldIds(contact, ProtectedReadService.ContactFieldAccessors); + + redacted.Should().OnlyContain(id => ProtectedReadService.ContactFieldAccessors.Keys.Contains(id)); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/User/AdpAddonBillingAuthorizationTests.cs b/Tests/Resgrid.Tests/Web/User/AdpAddonBillingAuthorizationTests.cs new file mode 100644 index 000000000..443ca8e8a --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/AdpAddonBillingAuthorizationTests.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Areas.User.Models.Subscription; +using Resgrid.Web.Options; + +namespace Resgrid.Tests.Web.User +{ + /// + /// The one hard difference between the ADP addon pages and every other addon page (plan 17.1): + /// buying and cancelling are restricted to Department.ManagingUserId, not to department + /// administrators generally. + /// + /// The reason is asymmetric consequence. Enrolling commits the department's data to a key it then + /// depends on to read its own records, and cancelling starts the migration that decrypts all of + /// it. A department can have many administrators; exactly one person owns the account, and that + /// is who answers for both of those. + /// + /// The page hides the buttons from anyone else, which is a courtesy. These tests are about the + /// server refusing the POST regardless of what the page drew. + /// + [TestFixture] + [NonParallelizable] + public class AdpAddonBillingAuthorizationTests + { + private const int DepartmentId = 10; + private const string ManagingUserId = "the-owner"; + private const string OtherAdminUserId = "an-admin"; + + private Mock _subscriptionsService; + private Mock _departmentsService; + private Mock _dataProtectionService; + + [TearDown] + public void TearDown() + { + Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = null; + } + + private SubscriptionController BuildController(string callerUserId) + { + var department = new Department + { + DepartmentId = DepartmentId, + Name = "Test Department", + ManagingUserId = ManagingUserId + }; + + _dataProtectionService = new Mock(); + _departmentsService = new Mock(); + _departmentsService.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, It.IsAny())).ReturnsAsync(department); + + var adpPlanAddon = new PlanAddon + { + PlanAddonId = "adp-addon", + AddonType = (int)PlanAddonTypes.ADP, + PlanId = 4, + Cost = 999 + }; + + _subscriptionsService = new Mock(); + _subscriptionsService.Setup(x => x.GetAllAddonPlansByTypeAsync(PlanAddonTypes.ADP)) + .ReturnsAsync(new List { adpPlanAddon }); + _subscriptionsService.Setup(x => x.GetPlanByIdAsync(4, It.IsAny())) + .ReturnsAsync(new Plan { PlanId = 4, Name = "ADP", Cost = 999 }); + _subscriptionsService.Setup(x => x.GetCurrentPlanForDepartmentAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(new Plan { PlanId = 2, Name = "Paid", Cost = 100 }); + _subscriptionsService.Setup(x => x.GetCurrentPaymentAddonsForDepartmentAsync(DepartmentId, It.IsAny>())) + .ReturnsAsync(new List + { + new PaymentAddon { PlanAddonId = "adp-addon", IsCancelled = false, EndingOn = DateTime.UtcNow.AddYears(1) } + }); + + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, callerUserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) + }, "test")) + }; + + // The audit event these actions write stamps the caller's IP, and IpAddressHelper throws + // when it cannot find one - a bare DefaultHttpContext has no connection. + httpContext.Connection.RemoteIpAddress = IPAddress.Loopback; + + Resgrid.Web.Helpers.ClaimsAuthorizationHelper._httpContextAccessor = + new HttpContextAccessor { HttpContext = httpContext }; + + return new SubscriptionController( + _departmentsService.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + _subscriptionsService.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Options.Create(new AppOptions()), + Mock.Of(), + _dataProtectionService.Object) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + } + + [Test] + public async Task A_department_admin_who_is_not_the_managing_member_cannot_buy_it() + { + var controller = BuildController(OtherAdminUserId); + + var result = await controller.BuyAdpAddon(new AdpAddonView { PlanAddonId = "adp-addon" }, CancellationToken.None); + + // SecureBaseController.Unauthorized() redirects rather than returning a 401 - this is a + // cookie-authenticated MVC page, not an API. + result.Should().BeOfType() + .Which.Url.Should().Be("/Public/Unauthorized"); + _subscriptionsService.Verify(x => x.AddAddonAddedToExistingSub(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task A_department_admin_who_is_not_the_managing_member_cannot_cancel_it() + { + var controller = BuildController(OtherAdminUserId); + + var result = await controller.CancelAdpAddon(CancellationToken.None); + + result.Should().BeOfType() + .Which.Url.Should().Be("/Public/Unauthorized"); + _subscriptionsService.Verify(x => x.CancelPlanAddonByTypeFromStripeAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task The_managing_member_can_cancel_it() + { + var controller = BuildController(ManagingUserId); + _subscriptionsService.Setup(x => x.CancelPlanAddonByTypeFromStripeAsync(DepartmentId, (int)PlanAddonTypes.ADP)) + .ReturnsAsync(true); + + await controller.CancelAdpAddon(CancellationToken.None); + + _subscriptionsService.Verify(x => x.CancelPlanAddonByTypeFromStripeAsync(DepartmentId, (int)PlanAddonTypes.ADP), + Times.Once); + } + + [Test] + public async Task A_free_department_cannot_buy_it_even_as_the_managing_member() + { + var controller = BuildController(ManagingUserId); + _subscriptionsService.Setup(x => x.GetCurrentPlanForDepartmentAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(new Plan { PlanId = 1, Name = "Forever Free", Cost = 0 }); + + await controller.BuyAdpAddon(new AdpAddonView { PlanAddonId = "adp-addon" }, CancellationToken.None); + + _subscriptionsService.Verify(x => x.AddAddonAddedToExistingSub(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never, "the addon requires an active paid plan (plan 17.1)"); + } + + [Test] + public async Task Buying_the_addon_never_touches_protection_state() + { + var controller = BuildController(ManagingUserId); + + await controller.BuyAdpAddon(new AdpAddonView { PlanAddonId = "adp-addon" }, CancellationToken.None); + + // Buying makes the department ELIGIBLE to enroll and nothing more. The wizard, run later + // and separately, is what commits data to a key. + _dataProtectionService.Verify(x => x.QueueEnrollmentAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + + _dataProtectionService.Verify(x => x.ScheduleOffboardingAsync(It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + + _subscriptionsService.Verify(x => x.AddAddonAddedToExistingSub(DepartmentId, It.IsAny(), + It.IsAny()), Times.Once, "the purchase itself still has to happen"); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/User/ProtectedUdfRevealVisibilityTests.cs b/Tests/Resgrid.Tests/Web/User/ProtectedUdfRevealVisibilityTests.cs index 0263024ae..fd685e705 100644 --- a/Tests/Resgrid.Tests/Web/User/ProtectedUdfRevealVisibilityTests.cs +++ b/Tests/Resgrid.Tests/Web/User/ProtectedUdfRevealVisibilityTests.cs @@ -172,5 +172,66 @@ public void Every_reveal_endpoint_passes_the_callers_real_admin_status() callers.Should().BeGreaterThanOrEqualTo(5, "contacts, calls, personnel, units and the profile page all reveal UDF values"); } + + /// + /// The UDF resolve is its own grant validation, and it is the one that can fail alone: a + /// record whose own cataloged columns are all empty produces no slots, so its resolve returns + /// without ever checking the grant. When the custom fields ARE enveloped, that second call is + /// where an expired grant surfaces. + /// + /// Discarding its result answers success with placeholders the client declines to write, so + /// the member clicks Reveal and nothing happens — no error, no re-prompt. Two of the five + /// endpoints shipped with exactly that omission, which is why this is pinned structurally + /// rather than left to review. + /// + [Test] + public void Every_reveal_endpoint_reports_a_udf_grant_failure_to_the_client() + { + var root = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (root != null && !System.IO.File.Exists(Path.Combine(root.FullName, "Resgrid.sln"))) + root = root.Parent; + + root.Should().NotBeNull("the tests must be able to find the repository root"); + + var controllers = Directory.GetFiles(Path.Combine(root!.FullName, "Web", "Resgrid.Web", + "Areas", "User", "Controllers"), "*.cs"); + + var checkedCallers = 0; + + foreach (var path in controllers) + { + var source = System.IO.File.ReadAllText(path); + if (!source.Contains("ProtectedUdfRevealHelper.AddUdfValuesAsync")) + continue; + + var name = Path.GetFileName(path); + + // Every call must be of the form "var = await ...AddUdfValuesAsync(...)", and + // .ProtectedReason must be read soon after. An unassigned call cannot report + // anything, and an assigned-but-unread one is the same bug wearing a variable. + foreach (System.Text.RegularExpressions.Match call in Regex.Matches(source, + @"var\s+(?\w+)\s*=\s*await\s+ProtectedUdfRevealHelper\.AddUdfValuesAsync\b[^;]*;(?[\s\S]{0,800})", + RegexOptions.None)) + { + checkedCallers++; + + var variable = call.Groups["variable"].Value; + call.Groups["tail"].Value.Should().Contain($"{variable}.ProtectedReason", + $"{name} must answer success:false with the machine-readable reason so the client can re-prompt for step-up"); + } + + // A call that is not assigned at all never reaches the loop above, so count them + // separately rather than letting them pass unnoticed. + var totalCalls = Regex.Matches(source, @"ProtectedUdfRevealHelper\.AddUdfValuesAsync\b").Count; + var assignedCalls = Regex.Matches(source, + @"var\s+\w+\s*=\s*await\s+ProtectedUdfRevealHelper\.AddUdfValuesAsync\b").Count; + + assignedCalls.Should().Be(totalCalls, + $"{name} must keep the reveal result — a dropped grant failure is invisible to the caller"); + } + + checkedCallers.Should().BeGreaterThanOrEqualTo(5, + "every reveal endpoint that loads UDF values has to be covered by this"); + } } } diff --git a/Tests/Resgrid.Tests/Web/User/SubscriptionControllerTests.cs b/Tests/Resgrid.Tests/Web/User/SubscriptionControllerTests.cs index 7532e98f5..e5719d7fb 100644 --- a/Tests/Resgrid.Tests/Web/User/SubscriptionControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/User/SubscriptionControllerTests.cs @@ -93,7 +93,8 @@ public async Task Index_TreatsSerializedMaxDateAsNeverExpiring() Mock.Of(), Mock.Of(), Options.Create(new AppOptions()), - Mock.Of()) + Mock.Of(), + Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = httpContext } }; diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs index 14c5fe8e8..7177ebdf8 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Resgrid.Model.Providers; @@ -127,6 +127,14 @@ public async Task> GetAllContacts() var contactData = ConvertContactData(contact, department, addedOnPerson, editedPerson); contactData.IsProtected = protectedRead.IsProtected; contactData.ProtectedReason = protectedRead.ProtectedReason; + + // Per ROW, not the batch union: a field redacted on one contact must not be + // reported as redacted on every other contact in the list. Without this the + // clients fall back to sniffing for the literal "REDACTED" string, which + // mistakes a member who typed that word for a protected value. + contactData.RedactedFields = Resgrid.Services.ProtectedReadService.GetRedactedFieldIds( + contact, Resgrid.Services.ProtectedReadService.ContactFieldAccessors); + result.Data.Add(contactData); } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs index 813c374a8..387eb7a6a 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs @@ -127,13 +127,72 @@ public async Task> Capabilities() } /// - /// Verifies the caller's authenticator (TOTP) code for the ADP step-up (plan section 3). - /// Success returns the absolute expiry of the step-up window — clients hold it in memory - /// only, conceal protected values at expiry, and prompt again on the next reveal/edit. - /// Refreshing an access token never refreshes this window. Allowed during a department lock: - /// step-up is a read-side control and reads continue while locked. Attempts are rate limited - /// per user; the code is never logged. + /// Issues a grant without a second factor for a client the department has exempted from the + /// step-up prompt (plan section 3.3). Refused with step_up_required for every other + /// client, which is what the app treats as "show the code prompt". + /// + /// The exemption is per client application and is off for every app until a department's + /// managing member turns it off deliberately. It removes the PROMPT, not the grant: the + /// caller is still authenticated, the grant is still bound to this department and policy + /// epoch, still expires, and still authorizes an audited read. /// + [HttpPost("RequestGrant")] + [AllowDuringDepartmentLock] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> RequestGrant() + { + var clientApp = int.TryParse(User.FindFirst(Model.Security.SessionClaimTypes.ClientApp)?.Value, out var parsed) + ? (UserSessionClientApplication)parsed + : UserSessionClientApplication.Api; + + // The exemption answer and the epoch the grant is stamped with come from ONE policy + // snapshot. Asking for them separately let a revocation land in between and mint a grant + // carrying the epoch that revocation had just bumped — a grant that outlived its own + // revocation. + var decision = await _dataProtectionService.GetStepUpDecisionForClientAsync(DepartmentId, clientApp); + + if (decision.StepUpRequired) + return Problem(type: "step_up_required", + title: "This department requires second-factor verification before protected values are shown.", + statusCode: StatusCodes.Status401Unauthorized); + + if (!_grantService.CanIssueGrants) + return Problem(type: "grants_not_configured", title: "Protected data grants are not configured.", + statusCode: StatusCodes.Status503ServiceUnavailable); + + var windowMinutes = decision.StepUpWindowMinutes > 0 + ? decision.StepUpWindowMinutes + : Config.DataProtectionConfig.StepUpWindowDefaultMinutes; + windowMinutes = Math.Min(Math.Max(1, windowMinutes), Math.Max(1, Config.DataProtectionConfig.StepUpMaximumMinutes)); + + var issued = _grantService.IssueGrant(new ProtectedDataGrantIssueRequest + { + UserId = UserId, + DepartmentId = DepartmentId, + SessionId = User.FindFirst(Model.Security.SessionClaimTypes.SessionId)?.Value, + ClientApp = (int)clientApp, + PolicyEpoch = decision.PolicyEpoch, + WindowMinutes = windowMinutes, + Scopes = new[] { ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write }, + MfaAtUtc = DateTime.UtcNow, + StepUpExempt = true + }); + + var exemptResult = new StepUpResult + { + GrantId = issued.GrantId, + GrantToken = issued.Token, + StepUpExpiresOnUtc = issued.ExpiresOnUtc.ToString("O"), + StepUpWindowMinutes = windowMinutes, + PageSize = 1, + Status = ResponseHelper.Success + }; + + ResponseHelper.PopulateV4ResponseData(exemptResult); + return exemptResult; + } + [HttpPost("VerifyStepUp")] [AllowDuringDepartmentLock] [ProducesResponseType(StatusCodes.Status200OK)] @@ -274,6 +333,12 @@ public async Task> RevokeOffboarding() /// epoch. On deployments without grant key material (CanValidateGrants false) the gate is /// inactive and the pre-Phase-2 gates (managing member, addon, global flag) stand alone. /// Returns null when the command may proceed. + /// + /// A step-up-EXEMPT grant is refused here. Those are minted by RequestGrant without any second + /// factor, for a client the department exempted from the reveal prompt (plan 3.3) — that + /// exemption covers reading protected values, not running enrollment or offboarding. Accepting + /// one would let an exempt client change the department's protection lifecycle with a password + /// alone, which is exactly what this gate exists to stop. /// private async Task RequireRecentMfaAsync() { @@ -286,6 +351,7 @@ private async Task RequireRecentMfaAsync() requiredScope: null, out var grant); if (outcome != ProtectedDataGrantValidationOutcome.Valid || + grant.StepUpExempt || !string.Equals(grant.UserId, UserId, StringComparison.OrdinalIgnoreCase)) return Problem(type: "step_up_required", title: "Recent multi-factor verification is required for this command. Verify your authenticator code and retry with the issued grant.", diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index f42704763..15fe1eb50 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1419,15 +1419,17 @@ values, ciphertext, or key material. - - - Verifies the caller's authenticator (TOTP) code for the ADP step-up (plan section 3). - Success returns the absolute expiry of the step-up window — clients hold it in memory - only, conceal protected values at expiry, and prompt again on the next reveal/edit. - Refreshing an access token never refreshes this window. Allowed during a department lock: - step-up is a read-side control and reads continue while locked. Attempts are rate limited - per user; the code is never logged. - + + + Issues a grant without a second factor for a client the department has exempted from the + step-up prompt (plan section 3.3). Refused with step_up_required for every other + client, which is what the app treats as "show the code prompt". + + The exemption is per client application and is off for every app until a department's + managing member turns it off deliberately. It removes the PROMPT, not the grant: the + caller is still authenticated, the grant is still bound to this department and policy + epoch, still expires, and still authorizes an audited read. + @@ -1447,15 +1449,21 @@ - - MFA-recency gate for enrollment/offboarding commands (plan sections 3.5 and 18): the - caller must present a currently-valid Protected Data Grant — minted by VerifyStepUp after - fresh TOTP, absolute lifetime = the department step-up window — in the - X-Resgrid-Protected-Grant header, bound to THIS user and department at the CURRENT policy - epoch. On deployments without grant key material (CanValidateGrants false) the gate is - inactive and the pre-Phase-2 gates (managing member, addon, global flag) stand alone. - Returns null when the command may proceed. - + + MFA-recency gate for enrollment/offboarding commands (plan sections 3.5 and 18): the + caller must present a currently-valid Protected Data Grant — minted by VerifyStepUp after + fresh TOTP, absolute lifetime = the department step-up window — in the + X-Resgrid-Protected-Grant header, bound to THIS user and department at the CURRENT policy + epoch. On deployments without grant key material (CanValidateGrants false) the gate is + inactive and the pre-Phase-2 gates (managing member, addon, global flag) stand alone. + Returns null when the command may proceed. + + A step-up-EXEMPT grant is refused here. Those are minted by RequestGrant without any second + factor, for a client the department exempted from the reveal prompt (plan 3.3) — that + exemption covers reading protected values, not running enrollment or offboarding. Accepting + one would let an exempt client change the department's protection lifecycle with a password + alone, which is exactly what this gate exists to stop. + diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs index c70db8e12..0bbdb744f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs @@ -991,10 +991,19 @@ public async Task RevealContact([FromForm] string contactId) bool isDeptAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); bool isGroupAdmin = await _departmentGroupsService.IsUserAGroupAdminAsync(UserId, DepartmentId); - await ProtectedUdfRevealHelper.AddUdfValuesAsync(fields, _userDefinedFieldsService, + var resolvedUdf = await ProtectedUdfRevealHelper.AddUdfValuesAsync(fields, _userDefinedFieldsService, _protectedReadService, DepartmentId, UdfEntityType.Contact, contactId, grantToken, UserId, isDeptAdmin, isGroupAdmin); + // The UDF resolve is a SEPARATE grant validation, and it is the one that can fail on its + // own: a record whose own cataloged columns are all empty produces no slots, so its + // resolve returns without ever checking the grant. If the custom fields are enveloped, + // this call is where an expired grant actually surfaces - and dropping the reason would + // answer success with placeholders the client silently declines to write, so the member + // clicks Reveal and nothing happens. Null when the record has no custom values at all. + if (resolvedUdf != null && resolvedUdf.IsProtected && resolvedUdf.ProtectedReason != null) + return Json(new { success = false, error = resolvedUdf.ProtectedReason }); + return Json(new { success = true, fields }); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs index ad8efb25f..4f4dbcb59 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc.Rendering; using Newtonsoft.Json; using Resgrid.Model; +using Resgrid.Model.Events; using Resgrid.Model.Providers; using Resgrid.Model.Services; using Resgrid.Web.Areas.User.Models.DataProtection; @@ -66,13 +67,15 @@ public class DataProtectionController : SecureBaseController private readonly UserManager _userManager; private readonly IProtectedDataGrantService _grantService; private readonly ICacheProvider _cacheProvider; + private readonly IEventAggregator _eventAggregator; public DataProtectionController(IDepartmentDataProtectionService dataProtectionService, IDepartmentLockService departmentLockService, IAdpSizingService sizingService, IProtectedDataBrokerClient brokerClient, IDepartmentsService departmentsService, UserManager userManager, IProtectedDataGrantService grantService, - ICacheProvider cacheProvider) + ICacheProvider cacheProvider, IEventAggregator eventAggregator) { + _eventAggregator = eventAggregator; _dataProtectionService = dataProtectionService; _departmentLockService = departmentLockService; _sizingService = sizingService; @@ -113,6 +116,8 @@ public async Task Index() model.ManagingMemberHasMfa = managingUser != null && await _userManager.GetTwoFactorEnabledAsync(managingUser); } + model.StepUpExemptClients = ((AdpStepUpExemptClients)(policy?.StepUpExemptClients ?? 0)).Sanitize(); + model.DefaultWindowStart = Config.DataProtectionConfig.MigrationWindowDefaultStartLocal; model.DefaultWindowEnd = Config.DataProtectionConfig.MigrationWindowDefaultEndLocal; model.TimeZones = TimeZoneInfo.GetSystemTimeZones() @@ -236,6 +241,105 @@ public async Task RevokeOffboarding(CancellationToken cancellatio /// conceals values at expiry, and prompts again on the next reveal. Rate limited per user; the /// code is never logged. Allowed during a department lock — step-up is a read-side control. /// + /// + /// Replaces the department's per-app step-up exemptions (plan 3.3). + /// + /// Requires a fresh second factor to change — you have to prove one to switch one off. That is + /// not ceremony: without it, anyone who walked up to a signed-in session could quietly remove + /// the control that would have stopped them, and the first sign would be plaintext on screen. + /// + /// Audited with the before and after mask. The service enforces managing-member only and bumps + /// the policy epoch so outstanding grants issued under the previous setting stop working. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [RequiresRecentTwoFactor(RequireForOperation = true)] + public async Task SaveStepUpExemptions([FromForm] int exemptions, CancellationToken cancellationToken) + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + var before = await _dataProtectionService.GetStepUpExemptClientsAsync(DepartmentId, bypassCache: true); + var requested = ((AdpStepUpExemptClients)exemptions).Sanitize(); + + var outcome = await _dataProtectionService.SetStepUpExemptClientsAsync(DepartmentId, requested, + UserId, cancellationToken); + + // Audited whatever the outcome: a REFUSED attempt to weaken this is at least as + // interesting as a successful one. + var auditEvent = new AuditEvent + { + DepartmentId = DepartmentId, + UserId = UserId, + Type = AuditLogTypes.DataProtectionStepUpExemptionsChanged, + Before = before.ToString(), + After = requested.ToString(), + Successful = outcome == DepartmentDataProtectionEnrollmentResult.Queued, + IpAddress = IpAddressHelper.GetRequestIP(Request, true), + ServerName = Environment.MachineName, + UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}" + }; + _eventAggregator.SendMessage(auditEvent); + + return MapOutcome(outcome); + } + + /// + /// Issues a grant WITHOUT a second factor, but only for a client the department has explicitly + /// exempted (plan 3.3). The client calls this first and falls back to the step-up modal when + /// it is refused, so the prompt appears exactly where the department left it switched on. + /// + /// This never weakens VerifyStepUp and never bypasses anything else: the caller is still an + /// authenticated member of the department, the grant is still tenant-bound, epoch-bound and + /// short-lived, and every read it authorizes is still audited. What is skipped is only the + /// second factor — and the grant records that it was skipped. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [AllowDuringDepartmentLock] + public async Task RequestGrant() + { + // The exemption answer and the epoch stamped on the grant come from ONE policy snapshot. + // Read separately, a managing member revoking the Web exemption between the two reads + // would have the check pass against the old policy while the grant took the epoch that + // revocation bumped — leaving a step-up-exempt grant alive after the revocation. + var decision = await _dataProtectionService.GetStepUpDecisionForClientAsync(DepartmentId, + UserSessionClientApplication.Web); + + if (decision.StepUpRequired) + return Json(new { success = false, error = "step_up_required" }); + + if (!_grantService.CanIssueGrants) + return Json(new { success = false, error = "grants_not_configured" }); + + var windowMinutes = decision.StepUpWindowMinutes > 0 + ? decision.StepUpWindowMinutes + : Config.DataProtectionConfig.StepUpWindowDefaultMinutes; + windowMinutes = Math.Min(Math.Max(1, windowMinutes), Math.Max(1, Config.DataProtectionConfig.StepUpMaximumMinutes)); + + var issued = _grantService.IssueGrant(new ProtectedDataGrantIssueRequest + { + UserId = UserId, + DepartmentId = DepartmentId, + SessionId = User.FindFirst(Model.Security.SessionClaimTypes.SessionId)?.Value, + ClientApp = (int)UserSessionClientApplication.Web, + PolicyEpoch = decision.PolicyEpoch, + WindowMinutes = windowMinutes, + Scopes = new[] { ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write }, + MfaAtUtc = DateTime.UtcNow, + StepUpExempt = true + }); + + return Json(new + { + success = true, + grantToken = issued.Token, + grantId = issued.GrantId, + expiresOnUtc = issued.ExpiresOnUtc.ToString("O"), + windowMinutes + }); + } + [HttpPost] [ValidateAntiForgeryToken] [AllowDuringDepartmentLock] diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 7a33f48b8..0f600069b 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -1402,10 +1402,19 @@ public async Task RevealCall([FromForm] int callId) bool isDeptAdmin = ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); bool isGroupAdmin = await _departmentGroupsService.IsUserAGroupAdminAsync(UserId, DepartmentId); - await ProtectedUdfRevealHelper.AddUdfValuesAsync(fields, _userDefinedFieldsService, + var resolvedUdf = await ProtectedUdfRevealHelper.AddUdfValuesAsync(fields, _userDefinedFieldsService, _protectedReadService, DepartmentId, UdfEntityType.Call, callId.ToString(), grantToken, UserId, isDeptAdmin, isGroupAdmin); + // The UDF resolve is a SEPARATE grant validation, and it is the one that can fail on its + // own: a record whose own cataloged columns are all empty produces no slots, so its + // resolve returns without ever checking the grant. If the custom fields are enveloped, + // this call is where an expired grant actually surfaces - and dropping the reason would + // answer success with placeholders the client silently declines to write, so the member + // clicks Reveal and nothing happens. Null when the record has no custom values at all. + if (resolvedUdf != null && resolvedUdf.IsProtected && resolvedUdf.ProtectedReason != null) + return Json(new { success = false, error = resolvedUdf.ProtectedReason }); + return Json(new { success = true, fields }); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs index 46c68371f..ebdf62c2f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs @@ -48,12 +48,15 @@ public class SubscriptionController : SecureBaseController private readonly IUserProfileService _userProfileService; private readonly IOptions _appOptionsAccessor; private readonly IEventAggregator _eventAggregator; + private readonly IDepartmentDataProtectionService _dataProtectionService; public SubscriptionController(IDepartmentsService departmentsService, IUsersService usersService, IDepartmentGroupsService departmentGroupsService, Model.Services.IAuthorizationService authorizationService, ISubscriptionsService subscriptionsService, IPersonnelRolesService personnelRolesService, IUnitsService unitsService, IDepartmentSettingsService departmentSettingsService, IEmailService emailService, IAffiliateService affiliateService, - IUserProfileService userProfileService, IOptions appOptionsAccessor, IEventAggregator eventAggregator) + IUserProfileService userProfileService, IOptions appOptionsAccessor, IEventAggregator eventAggregator, + IDepartmentDataProtectionService dataProtectionService) { + _dataProtectionService = dataProtectionService; _departmentsService = departmentsService; _usersService = usersService; _departmentGroupsService = departmentGroupsService; @@ -71,6 +74,207 @@ public SubscriptionController(IDepartmentsService departmentsService, IUsersServ #endregion Private Members and Constructors + #region Advanced Data Protection addon + + /// + /// The ADP addon plan for this data center. Resolved by TYPE rather than by a hardcoded id + /// like the PTT pages use: the addon is seeded once per data center with its own id, and a + /// literal here would work in one region and quietly fail in the other. + /// + private async Task GetAdpAddonPlanAsync() + { + var plans = await _subscriptionsService.GetAllAddonPlansByTypeAsync(PlanAddonTypes.ADP); + return plans?.FirstOrDefault(); + } + + /// + /// Plan 17.1: every ADP billing action is restricted to the department's managing member, + /// server-side. Not "an administrator" — enrolling commits the department's data to a key it + /// then depends on, and cancelling starts the migration that undoes it, so both stay with the + /// single person who owns the account. + /// + private async Task IsAdpManagingMemberAsync() + { + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + return department != null && !string.IsNullOrWhiteSpace(department.ManagingUserId) + && string.Equals(department.ManagingUserId, UserId, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Loads everything both ADP addon pages render. Billing facts come from the addon rows and + /// protection facts from the policy, and they are kept apart on purpose — see AdpAddonView. + /// + private async Task BuildAdpAddonViewAsync() + { + var model = new AdpAddonView(); + + model.PlanAddon = await GetAdpAddonPlanAsync(); + if (model.PlanAddon == null) + return null; + + model.PlanAddonId = model.PlanAddon.PlanAddonId; + model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + model.IsManagingMember = model.Department != null + && !string.IsNullOrWhiteSpace(model.Department.ManagingUserId) + && string.Equals(model.Department.ManagingUserId, UserId, StringComparison.OrdinalIgnoreCase); + + model.Price = model.PlanAddon.Cost.ToString("C0", Cultures.UnitedStates); + + var currentPlan = await _subscriptionsService.GetCurrentPlanForDepartmentAsync(DepartmentId); + model.HasPaidPlan = currentPlan != null && currentPlan.Cost > 0; + + var addons = await _subscriptionsService.GetCurrentPaymentAddonsForDepartmentAsync(DepartmentId, + new List { model.PlanAddon.PlanAddonId }); + + var addon = addons?.OrderByDescending(x => x.EndingOn).FirstOrDefault(); + if (addon != null) + { + model.HasAddon = true; + model.IsCancelled = addon.IsCancelled; + model.EndingOn = addon.EndingOn; + } + + // Protection state is read fresh: a member who has just enrolled or cancelled is looking + // at this page precisely to see whether it took effect. + var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(DepartmentId, bypassCache: true); + model.ProtectionState = policy == null + ? DepartmentDataProtectionState.Disabled + : (DepartmentDataProtectionState)policy.State; + model.PaidThroughOn = policy?.AddonPaidThroughOn; + model.GraceEndsOn = policy?.AddonGraceEndsOn; + model.OffboardingEffectiveOn = policy?.OffboardingEffectiveOn; + + return model; + } + + /// Purchase page for the ADP addon (plan 17.1). + [HttpGet] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task BuyAdpAddon() + { + var model = await BuildAdpAddonViewAsync(); + if (model == null) + return StatusCode(StatusCodes.Status500InternalServerError, "Unable to load the Advanced Data Protection add-on. Please try again."); + + // An active addon belongs on the management page; sending them there beats rendering a + // buy button that the POST would refuse. + if (model.HasAddon && !model.IsCancelled) + return RedirectToAction("ManageAdpAddon", "Subscription", new { Area = "User" }); + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Department_Update)] + [RequiresRecentTwoFactor] + public async Task BuyAdpAddon(AdpAddonView postedModel, CancellationToken cancellationToken) + { + try + { + // Re-checked here rather than trusted from the page: the GET only decides what to draw. + if (!await IsAdpManagingMemberAsync()) + return Unauthorized(); + + var addonPlan = await GetAdpAddonPlanAsync(); + if (addonPlan == null || !addonPlan.PlanId.HasValue) + return StatusCode(StatusCodes.Status500InternalServerError, "Unable to load the Advanced Data Protection add-on. Please try again."); + + var currentPlan = await _subscriptionsService.GetCurrentPlanForDepartmentAsync(DepartmentId); + if (currentPlan == null || currentPlan.Cost <= 0) + return RedirectToAction("BuyAdpAddon", "Subscription", new { Area = "User" }); + + var plan = await _subscriptionsService.GetPlanByIdAsync(addonPlan.PlanId.Value); + if (plan == null) + return StatusCode(StatusCodes.Status500InternalServerError, "Unable to load the Advanced Data Protection plan. Please try again."); + + // Audited AFTER the provider call, with the provider's own answer. Recorded first it + // claimed success for a purchase the billing API may then have refused, which is the + // one thing an addon audit trail must never do. + var purchased = await _subscriptionsService.AddAddonAddedToExistingSub(DepartmentId, plan, addonPlan); + + var auditEvent = new AuditEvent(); + auditEvent.Before = null; + auditEvent.DepartmentId = DepartmentId; + auditEvent.UserId = UserId; + auditEvent.Type = AuditLogTypes.AddonSubscriptionModified; + auditEvent.After = $"ADP addon purchased ({addonPlan.PlanAddonId})"; + auditEvent.Successful = purchased != null; + auditEvent.IpAddress = IpAddressHelper.GetRequestIP(Request, true); + auditEvent.ServerName = Environment.MachineName; + auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + _eventAggregator.SendMessage(auditEvent); + + // The provider's webhook is what actually activates the addon in Core; this page only + // starts the purchase. Nothing about protection changes here either way - the + // department enrolls afterwards, from the Data Protection page, when it chooses to. + return RedirectToAction("PaymentComplete", "Subscription", new { Area = "User", planId = plan.PlanId }); + } + catch (Exception ex) + { + Logging.SendExceptionEmail(ex, "BuyAdpAddon", DepartmentId, UserName); + + return RedirectToAction("PaymentFailed", "Subscription", + new { Area = "User", chargeId = "", errorMessage = ex.Message }); + } + } + + /// Management page for an ADP addon the department already holds (plan 17.1). + [HttpGet] + [Authorize(Policy = ResgridResources.Department_Update)] + public async Task ManageAdpAddon() + { + var model = await BuildAdpAddonViewAsync(); + if (model == null) + return StatusCode(StatusCodes.Status500InternalServerError, "Unable to load the Advanced Data Protection add-on. Please try again."); + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Department_Update)] + [RequiresRecentTwoFactor] + public async Task CancelAdpAddon(CancellationToken cancellationToken) + { + try + { + if (!await IsAdpManagingMemberAsync()) + return Unauthorized(); + + // Cancels the BILLING subscription only. Protection keeps running until the provider's + // cancellation event reaches Core and the offboarding migration it schedules actually + // runs; nothing here touches a key or a ciphertext. + // + // Audited AFTER the call, with the provider's own answer: recorded first it claimed a + // cancellation the billing API may then have refused. + var cancelled = await _subscriptionsService.CancelPlanAddonByTypeFromStripeAsync(DepartmentId, (int)PlanAddonTypes.ADP); + + var auditEvent = new AuditEvent(); + auditEvent.Before = null; + auditEvent.DepartmentId = DepartmentId; + auditEvent.UserId = UserId; + auditEvent.Type = AuditLogTypes.AddonSubscriptionModified; + auditEvent.After = "ADP addon cancelled"; + auditEvent.Successful = cancelled; + auditEvent.IpAddress = IpAddressHelper.GetRequestIP(Request, true); + auditEvent.ServerName = Environment.MachineName; + auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + _eventAggregator.SendMessage(auditEvent); + + return RedirectToAction("ManageAdpAddon", "Subscription", new { Area = "User" }); + } + catch (Exception ex) + { + Logging.SendExceptionEmail(ex, "CancelAdpAddon", DepartmentId, UserName); + + return RedirectToAction("PaymentFailed", "Subscription", + new { Area = "User", chargeId = "", errorMessage = ex.Message }); + } + } + + #endregion Advanced Data Protection addon + private static bool ShouldUsePaddleForSubscriptionFlow(Payment currentPayment, string paddleCustomerId) { if (!string.IsNullOrWhiteSpace(paddleCustomerId)) diff --git a/Web/Resgrid.Web/Areas/User/Models/DataProtection/DataProtectionIndexView.cs b/Web/Resgrid.Web/Areas/User/Models/DataProtection/DataProtectionIndexView.cs index c01bc6367..730e264c9 100644 --- a/Web/Resgrid.Web/Areas/User/Models/DataProtection/DataProtectionIndexView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/DataProtection/DataProtectionIndexView.cs @@ -44,6 +44,21 @@ public class DataProtectionIndexView public string DefaultWindowStart { get; set; } public string DefaultWindowEnd { get; set; } + + /// The department's current per-app step-up exemptions (plan 3.3). None by default. + public AdpStepUpExemptClients StepUpExemptClients { get; set; } + + /// The apps a department may exempt, in the order the settings card lists them. + public static readonly IReadOnlyList<(AdpStepUpExemptClients Flag, string LabelKey)> ExemptableClients = + new[] + { + (AdpStepUpExemptClients.Web, "StepUpClientWeb"), + (AdpStepUpExemptClients.Dispatch, "StepUpClientDispatch"), + (AdpStepUpExemptClients.Responder, "StepUpClientResponder"), + (AdpStepUpExemptClients.Unit, "StepUpClientUnit"), + (AdpStepUpExemptClients.Command, "StepUpClientCommand"), + (AdpStepUpExemptClients.Api, "StepUpClientApi") + }; } /// POST body for the wizard's final queue step. Acknowledgements must ALL be true. diff --git a/Web/Resgrid.Web/Areas/User/Models/Subscription/AdpAddonView.cs b/Web/Resgrid.Web/Areas/User/Models/Subscription/AdpAddonView.cs new file mode 100644 index 000000000..0bb72236f --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Subscription/AdpAddonView.cs @@ -0,0 +1,65 @@ +using System; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.Subscription +{ + /// + /// The ADP addon purchase and management pages (plan section 17.1). + /// + /// Modelled on with one hard difference: every ADP billing action is + /// restricted to Department.ManagingUserId, server-side. + /// only decides what the page draws — the controller re-checks it on every action, because a + /// hidden button is not an authorization control. + /// + /// The billing facts and the protection facts are deliberately kept as separate properties and + /// never merged into one "is it on" flag. Buying the addon encrypts nothing, and cancelling + /// decrypts nothing; only the enrollment and offboarding migrations move ciphertext, and a page + /// that blurred the two would tell a member their data was safe before it was. + /// + public class AdpAddonView : BaseUserModel + { + public Department Department { get; set; } + + public PlanAddon PlanAddon { get; set; } + + public string PlanAddonId { get; set; } + + /// Yearly price, already formatted for the department's currency. + public string Price { get; set; } + + /// Caller is the department's managing member — the only identity that may buy or cancel. + public bool IsManagingMember { get; set; } + + /// The addon requires an active paid plan; a free department may look but not buy. + public bool HasPaidPlan { get; set; } + + /// An addon row exists for the department, cancelled or not. + public bool HasAddon { get; set; } + + /// The addon is cancelled and running out its paid period. + public bool IsCancelled { get; set; } + + /// End of the current billing period, from the addon row. + public DateTime? EndingOn { get; set; } + + /// Durable protection state. Disabled with an active addon means "bought, not yet enrolled". + public DepartmentDataProtectionState ProtectionState { get; set; } + + /// What billing has been paid through, as recorded on the protection policy. + public DateTime? PaidThroughOn { get; set; } + + /// + /// End of the lapse grace window, when payment is outstanding. Present means "we are waiting + /// for money and protection is running anyway until this date". + /// + public DateTime? GraceEndsOn { get; set; } + + /// When a scheduled offboarding migration takes effect. + public DateTime? OffboardingEffectiveOn { get; set; } + + /// True while payment is outstanding and the grace window has not run out. + public bool IsInGrace => GraceEndsOn.HasValue && GraceEndsOn.Value > DateTime.UtcNow; + + public string ErrorMessage { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml index 5ae2c41a8..47e8fca9f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml @@ -291,6 +291,65 @@ } + + @* + Verification prompt settings (plan 3.3). Rendered only for a protected department: there is + nothing to prompt for until protection is on. Every app defaults to prompting and stays that + way until the managing member deliberately turns one off, which is why the checkbox means + "stop prompting" rather than "protect this". + *@ + @if (Model.State != DepartmentDataProtectionState.Disabled) + { +
+
+
+
+
@localizer["StepUpSectionTitle"]
+
+
+

@localizer["StepUpSectionIntro"]

+
@localizer["StepUpSectionWarning"]
+ + @if (!Model.IsManagingMember) + { +
@localizer["StepUpManagingMemberOnly"]
+ } + + + + @foreach (var client in Resgrid.Web.Areas.User.Models.DataProtection.DataProtectionIndexView.ExemptableClients) + { + var isExempt = (Model.StepUpExemptClients & client.Flag) != 0; + + + + + } + +
@localizer[client.LabelKey] +
+ +
+
+ +

@localizer["StepUpAuditNotice"]

+ + @if (Model.IsManagingMember) + { + + + } +
+
+
+
+ } @section Scripts @@ -317,4 +376,41 @@ }; + + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealScripts.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealScripts.cshtml index ecedade36..72787a6ce 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealScripts.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealScripts.cshtml @@ -37,6 +37,9 @@ $(function () { resgridAdpReveal.init({ verifyUrl: '@Url.Action("VerifyStepUp", "DataProtection", new { area = "User" })', + @* Asked before the prompt is shown; answers step_up_required unless the department has + exempted this app, in which case it returns a grant directly (plan 3.3). *@ + requestGrantUrl: '@Url.Action("RequestGrant", "DataProtection", new { area = "User" })', @* A page with no per-record reveal endpoint (its values come from the API through its own component) leaves these unset; step-up alone is the whole job and the host reloads through onRevealed. *@ diff --git a/Web/Resgrid.Web/Areas/User/Views/Subscription/BuyAdpAddon.cshtml b/Web/Resgrid.Web/Areas/User/Views/Subscription/BuyAdpAddon.cshtml new file mode 100644 index 000000000..55e72c9ee --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Subscription/BuyAdpAddon.cshtml @@ -0,0 +1,77 @@ +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Subscription.AdpAddonView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + @localizer["AddonPageTitle"]; + + // Both conditions are re-checked server-side in the POST. Hiding the button is a courtesy, not + // the control: plan 17.1 restricts every ADP billing action to the managing member. + var canPurchase = Model.IsManagingMember && Model.HasPaidPlan; +} + +
+
+

@localizer["AddonBuyHeading"]

+ +
+
+ +
+
+
+
+
+
@localizer["AddonPageTitle"]
+
+
+
+
+

@localizer["AddonSummary"]

+
    +
  • @localizer["AddonFeatureEncryption"]
  • +
  • @localizer["AddonFeatureStepUp"]
  • +
  • @localizer["AddonFeatureEgress"]
  • +
  • @localizer["AddonFeatureAudit"]
  • +
+
+ @localizer["AddonPurchaseSeparateFromEnrollment"] +
+
+
+

@Model.Price

+

@localizer["AddonPerYear"]

+ + @if (!Model.HasPaidPlan) + { +
@localizer["AddonPaidPlanRequired"]
+ } + else if (!Model.IsManagingMember) + { +
@localizer["AddonManagingMemberOnly"]
+ } + + @if (canPurchase) + { +
+ @Html.AntiForgeryToken() + + +
+ } +
+
+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml index dd9c0cd55..8febf6b6e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml @@ -1,4 +1,4 @@ -@using Newtonsoft.Json +@using Newtonsoft.Json @using Resgrid.Framework @using Resgrid.Model @using Resgrid.Config @@ -502,6 +502,49 @@ + + @* + Advanced Data Protection addon (ADP plan 17.1). One link for both states: the + manage page offers the purchase when the department does not hold the addon + yet, so this card does not need to know which state it is in. + *@ +
+
+
+
+
+
+
+

Advanced Data Protection Addon

+
+
+
+
+
    +
  • Per-department field encryption
  • +
  • Second-factor reveal of protected values
  • +
  • Email, SMS and push egress controls
  • +
  • Value-free audit of protected reads
  • +
  • Overnight, resumable enrollment
  • +
+
+
+
+ +

@currencySymbol999/yr

per department +
+
+
+

Advanced Data Protection encrypts your department's sensitive fields with a key held only for your department, and hides them behind a second-factor check. It is available on paid plans, billed yearly, and only your department's managing member can buy or cancel it. Buying the addon does not encrypt anything on its own — you enroll afterwards, from the Data Protection settings page, at a time you choose.

+ Manage Data Protection +
+
+
+
+
+
+
+
} diff --git a/Web/Resgrid.Web/Areas/User/Views/Subscription/ManageAdpAddon.cshtml b/Web/Resgrid.Web/Areas/User/Views/Subscription/ManageAdpAddon.cshtml new file mode 100644 index 000000000..ebbd802a5 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Subscription/ManageAdpAddon.cshtml @@ -0,0 +1,119 @@ +@using Newtonsoft.Json +@using Resgrid.Model +@model Resgrid.Web.Areas.User.Models.Subscription.AdpAddonView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + @localizer["AddonPageTitle"]; + + // Billing status and protection status are shown as two separate lines throughout this page. + // They genuinely can disagree - a cancelled addon still protects until its offboarding + // migration runs - and collapsing them into one badge would tell a member their data was + // already decrypted while it was not, or the reverse. + var canCancel = Model.IsManagingMember && Model.HasAddon && !Model.IsCancelled; + + // JavaScript-encoded, not HTML-encoded. Html.Encode turns an apostrophe into ', which the + // browser decodes back to ' while building the attribute value - the JS parser then sees an + // unterminated string and the confirm() prompt silently stops running. Same escaping Index.cshtml + // uses for the values it hands to script. + var cancelConfirmJson = Html.Raw(JsonConvert.SerializeObject(localizer["AddonCancelConfirm"].Value, + new JsonSerializerSettings { StringEscapeHandling = StringEscapeHandling.EscapeHtml })); +} + +
+
+

@localizer["AddonManageHeading"]

+ +
+
+ +
+
+
+
+
+
@localizer["AddonPageTitle"]
+
+
+ @if (!Model.HasAddon) + { +
@localizer["AddonNotPurchased"]
+ @localizer["AddonBuyButton"] + } + else + { +
+
@localizer["AddonStatusLabel"]
+
+ @if (Model.IsCancelled) + { + @localizer["AddonStatusCancelled"] + } + else + { + @localizer["AddonStatusActive"] + } +
+ + @if (Model.EndingOn.HasValue) + { +
@localizer["AddonRenewsOnLabel"]
+
@Model.EndingOn.Value.ToString("D")
+ } + + @if (Model.PaidThroughOn.HasValue) + { +
@localizer["AddonPaidThroughLabel"]
+
@Model.PaidThroughOn.Value.ToString("D")
+ } + +
@localizer["AddonProtectionStateLabel"]
+
@Model.ProtectionState.ToString()
+
+ + @if (Model.IsInGrace) + { +
+ @string.Format(localizer["AddonGraceNotice"].Value, Model.GraceEndsOn.Value.ToString("D")) +
+ } + + @if (Model.OffboardingEffectiveOn.HasValue) + { +
+ @string.Format(localizer["AddonOffboardingNotice"].Value, Model.OffboardingEffectiveOn.Value.ToString("D")) +
+ } + + @localizer["AddonOpenSettings"] + + @if (canCancel) + { +
+

@localizer["AddonCancelWarning"]

+
+ @Html.AntiForgeryToken() + +
+ } + else if (!Model.IsManagingMember) + { +
+
@localizer["AddonManagingMemberOnly"]
+ } + } +
+
+
+
+
diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js index 020ebdc72..1526da9f6 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js @@ -8,7 +8,7 @@ var REDACTED = 'REDACTED'; - var settings = null; // { verifyUrl, revealUrl, revealData, antiForgeryToken, messages, onRevealed, onConcealed } + var settings = null; // { verifyUrl, requestGrantUrl, revealUrl, revealData, antiForgeryToken, messages, onRevealed, onConcealed } var grantToken = null; var expiryTimer = null; var revealed = false; @@ -196,6 +196,37 @@ }); } + // A department may release named apps from the step-up prompt (plan 3.3) - a dispatcher on a + // live incident cannot stop to read a code off a phone. Ask the server first: it answers with a + // grant when this department has exempted this app, and with step_up_required otherwise, which + // is what puts the prompt back. The client never decides this; it only asks. + // + // Any failure falls through to the prompt. Erring towards asking for a second factor is the + // direction that cannot cause harm. + function requestGrantWithoutStepUp() { + if (!settings.requestGrantUrl) { + showStepUpModal(); + return; + } + + $.post(settings.requestGrantUrl, { __RequestVerificationToken: settings.antiForgeryToken }) + .done(function (response) { + if (response && response.success && response.grantToken) { + grantToken = response.grantToken; + if (response.expiresOnUtc) + scheduleConceal(response.expiresOnUtc); + + doReveal(); + return; + } + + showStepUpModal(); + }) + .fail(function () { + showStepUpModal(); + }); + } + function showStepUpModal() { $('#adpStepUpError').hide().text(''); $('#adpStepUpCode').val(''); @@ -293,10 +324,12 @@ settings = options; $('#adpRevealButton').on('click', function () { - if (grantToken) + if (grantToken) { doReveal(); - else - showStepUpModal(); + return; + } + + requestGrantWithoutStepUp(); }); $('#adpConcealButton').on('click', conceal).hide(); diff --git a/Workers/Resgrid.Workers.Framework/Logic/AdpMigrationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/AdpMigrationLogic.cs index de608b781..4b5644b63 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/AdpMigrationLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/AdpMigrationLogic.cs @@ -189,6 +189,7 @@ private static bool IsWorkableState(DepartmentDataProtectionState state) case DepartmentDataProtectionState.EnrollmentQueued: case DepartmentDataProtectionState.ProvisioningKey: case DepartmentDataProtectionState.Encrypting: + case DepartmentDataProtectionState.Rotating: case DepartmentDataProtectionState.Verifying: case DepartmentDataProtectionState.DisableRequested: case DepartmentDataProtectionState.Decrypting: @@ -345,6 +346,36 @@ await NotifyAdminsAsync(departmentId, context.TargetKeyVersion = activeKey?.Version; } + // A rotation re-encrypts an already-protected corpus under the new key version. It + // runs the same night as an enrollment because the engine's encrypt path IS the + // re-key path - it decrypts each envelope to validate it anyway, so a rotation is + // that decrypt followed by an encrypt under the new version. + if (state == DepartmentDataProtectionState.Rotating) + { + var rotationNight = await _engine.RunEncryptionNightAsync(context, cancellationToken); + if (rotationNight.Outcome == AdpMigrationNightOutcome.WindowClosed) + { + await NotifyAdminsAsync(departmentId, + $"Advanced Data Protection: tonight's key rotation checkpoint is complete ({rotationNight.PercentComplete?.ToString() ?? "?"}% done). Your department is back in full service; work resumes the next scheduled night."); + return $"department {departmentId}: rotation checkpointed"; + } + + if (rotationNight.Outcome == AdpMigrationNightOutcome.Failed) + { + releaseKind = DepartmentOperationLockReleaseKind.Aborted; + await FailInFlightMigrationAsync(departmentId, rotationNight.ErrorCode, cancellationToken); + await NotifyFailureAsync(departmentId); + return $"department {departmentId}: rotation failed ({rotationNight.ErrorCode})"; + } + + if (await _policyRepository.TryTransitionStateAsync(departmentId, DepartmentDataProtectionState.Rotating, + DepartmentDataProtectionState.Verifying, (int)DepartmentDataProtectionMigrationKind.Rotation, + WorkerIdentity, cancellationToken) == 0) + return $"department {departmentId}: verify transition race"; + + state = DepartmentDataProtectionState.Verifying; + } + if (state == DepartmentDataProtectionState.Encrypting) { // Before the sweep: move any member data still sitting in the legacy global @@ -471,6 +502,21 @@ await NotifyAdminsAsync(departmentId, return $"department {departmentId}: catalog upgrade complete at v{context.CatalogVersion}"; } + if (kind == DepartmentDataProtectionMigrationKind.Rotation) + { + // Retirement happens ONLY here, after verification proved no envelope still + // references a superseded version (plan 11.3: "retires old versions only after + // all copies and restore tests pass"). Retiring earlier would make any row the + // sweep had not reached yet unreadable. The rows themselves are never deleted - + // cryptographic erasure is a separate dual-controlled operation. + var retired = await RetireSupersededKeyVersionsAsync(departmentId, context.TargetKeyVersion ?? 0, cancellationToken); + + await NotifyAdminsAsync(departmentId, + "Advanced Data Protection: your department's encryption key has been rotated and verification passed. No action is needed."); + releaseKind = DepartmentOperationLockReleaseKind.Completed; + return $"department {departmentId}: rotation complete at key v{context.TargetKeyVersion}, {retired} version(s) retired"; + } + await NotifyAdminsAsync(departmentId, "Advanced Data Protection: verification passed and protection is now ACTIVE for your department."); releaseKind = DepartmentOperationLockReleaseKind.Completed; @@ -500,6 +546,66 @@ await NotifyAdminsAsync(departmentId, } } + /// + /// Retires every Retiring version below the rotation target. Called only after verification, + /// so by this point nothing references them. Failures here are logged rather than failing the + /// run: the data is fully re-keyed and readable, and a version left Retiring is a metadata + /// tidy-up an operator can repeat, not a reason to unwind a successful rotation. + /// + private async Task RetireSupersededKeyVersionsAsync(int departmentId, int targetVersion, CancellationToken cancellationToken) + { + if (targetVersion <= 0) + return 0; + + var retired = 0; + var stillRetiring = new List(); + + try + { + var versions = await _keyService.GetAllVersionsAsync(departmentId); + + foreach (var key in (versions ?? Array.Empty()) + .Where(k => k.Version < targetVersion && + (DepartmentDataProtectionKeyStatus)k.Status == DepartmentDataProtectionKeyStatus.Retiring)) + { + // Each version is retired independently. One version that will not retire - a KMS + // blip, a row another process is holding - must not skip the versions after it: + // the run reports complete and the department returns to Enabled, which the sweep + // does not pick up again, so anything passed over here waits for the next rotation. + try + { + if (await _keyService.RetireKeyVersionAsync(departmentId, key.Version, cancellationToken)) + retired++; + else + stillRetiring.Add(key.Version); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Shutdown, not a retirement failure: stop the loop rather than logging every + // remaining version as a problem. + throw; + } + catch (Exception ex) + { + stillRetiring.Add(key.Version); + Logging.LogException(ex, $"ADP rotation for department {departmentId} could not retire key v{key.Version}; continuing with the remaining versions."); + } + } + } + catch (Exception ex) + { + Logging.LogException(ex, $"ADP rotation for department {departmentId} completed but retiring superseded key versions failed."); + } + + // Named explicitly so an operator can repeat the tidy-up without diffing key tables. The + // data itself is fully re-keyed and readable either way - this is metadata that stayed + // behind, not a rotation that has to be unwound. + if (stillRetiring.Count > 0) + Logging.LogError($"ADP rotation for department {departmentId} left key version(s) {string.Join(", ", stillRetiring.Select(v => $"v{v}"))} in Retiring; they are not swept again until the next rotation and need an operator to retire them."); + + return retired; + } + /// Moves an in-flight (transitional) state to Failed, preserving the migration kind for resume. private async Task FailInFlightMigrationAsync(int departmentId, string errorCode, CancellationToken cancellationToken) {