diff --git a/Core/Resgrid.Config/DataProtectionConfig.cs b/Core/Resgrid.Config/DataProtectionConfig.cs
new file mode 100644
index 000000000..f87e68266
--- /dev/null
+++ b/Core/Resgrid.Config/DataProtectionConfig.cs
@@ -0,0 +1,158 @@
+namespace Resgrid.Config
+{
+ ///
+ /// Advanced Data Protection (ADP) platform configuration. Endpoint addresses, mounts and key
+ /// names are configuration; SECRETS ARE NOT — the broker's client certificate and key, the
+ /// OpenBao token, the YubiHSM PIN, and recovery shares must never appear here, in
+ /// appsettings*.json, resgrid.env, container images, or the repository. Values load like every
+ /// other Resgrid.Config class: "DataProtectionConfig.FieldName" JSON keys or
+ /// RESGRID:DataProtectionConfig:FieldName environment variables.
+ ///
+ public static class DataProtectionConfig
+ {
+ /// Base URL of the Protected Data Broker service (empty = no broker deployed).
+ public static string BrokerBaseUrl = "";
+
+ /// Audience the application tier expects on broker mTLS/workload credentials.
+ public static string BrokerAudience = "resgrid-protected-broker";
+
+ /// Broker request timeout in milliseconds; protected operations fail closed on expiry.
+ public static int BrokerTimeoutMs = 10000;
+
+ ///
+ /// Shared workload secret the application tier presents to the broker (X-Resgrid-Broker-Key).
+ /// Supplied through the environment/secret store only; an empty value on the broker refuses
+ /// every request (fail closed). This is defense-in-depth UNDER network isolation and mTLS —
+ /// never the only control.
+ ///
+ public static string BrokerApiKey = "";
+
+ /// Maximum field items one broker request may carry; larger requests are refused.
+ public static int BrokerMaxItemsPerRequest = 200;
+
+ /// True on the broker host to run the ADP migration coordinator sweep there (the only
+ /// host with a real KMS adapter). Workers.Console keeps its sweep for liveness/offboarding
+ /// flips but never runs nights — its engine reports unavailable.
+ public static bool BrokerRunsMigrations = true;
+
+ /// Broker-hosted migration sweep interval in seconds (matches worker command 27).
+ public static int BrokerMigrationSweepSeconds = 300;
+
+ /// Issuer (iss) on Protected Data Grants — the identity tier's logical name.
+ public static string GrantIssuer = "resgrid-identity";
+
+ /// Audience (aud) on Protected Data Grants, pinned by the broker and API validators.
+ public static string GrantAudience = "resgrid-protected-data";
+
+ ///
+ /// Filesystem path to the grant SIGNING certificate (PFX with an ECDSA P-256 private key).
+ /// Present ONLY on identity-tier hosts (the step-up endpoint); the broker gets the public
+ /// validation certificate instead. The path is configuration; the file is a mounted secret.
+ ///
+ public static string GrantSigningCertificatePath = "";
+
+ /// PFX password for the signing certificate, supplied through the environment only.
+ public static string GrantSigningCertificatePassword = "";
+
+ ///
+ /// Filesystem path to the grant VALIDATION certificate (public key only, CER/PEM/PFX). Set on
+ /// broker and API hosts. When empty, validation falls back to the signing certificate's
+ /// public part where that is configured (single-host development).
+ ///
+ public static string GrantValidationCertificatePath = "";
+
+ /// Bounded clock skew allowed when validating grant lifetimes, in seconds.
+ public static int GrantClockSkewSeconds = 30;
+
+ ///
+ /// Key-wrapping provider the broker uses: "OpenBaoTransit" (production default), or "LocalDev"
+ /// for synthetic/non-PHI testing only — production startup must reject LocalDev.
+ ///
+ public static string KeyWrappingProviderType = "OpenBaoTransit";
+
+ /// OpenBao base address, reachable ONLY from broker hosts (never Web/API/workers).
+ public static string OpenBaoAddress = "";
+
+ /// OpenBao Transit mount path.
+ public static string OpenBaoTransitMount = "transit";
+
+ /// Derived (per-department context) Transit KEK name.
+ public static string OpenBaoTransitKeyName = "resgrid-dept-kek";
+
+ ///
+ /// Filesystem path to the broker's mTLS client certificate (PFX/PKCS#12) used for the OpenBao
+ /// cert auth method. The path is configuration; the certificate FILE is a mounted secret and
+ /// must never land in appsettings*.json, resgrid.env, container images, or the repository.
+ ///
+ public static string OpenBaoClientCertificatePath = "";
+
+ /// PFX password, supplied through the environment/secret store only.
+ public static string OpenBaoClientCertificatePassword = "";
+
+ /// Optional named cert-auth role ("name" parameter on auth/cert/login); empty = any matching role.
+ public static string OpenBaoCertAuthRoleName = "";
+
+ /// OpenBao HTTP request timeout in milliseconds; unwrap/wrap fail closed on expiry.
+ public static int OpenBaoTimeoutMs = 10000;
+
+ /// Default Protected Data Grant lifetime in minutes when a department has no policy value.
+ public static int StepUpWindowDefaultMinutes = 15;
+
+ /// Department values above this trigger an administrator warning plus recorded reason.
+ public static int StepUpWarningThresholdMinutes = 60;
+
+ /// Operator ceiling on StepUpWindowMinutes; departments cannot exceed it.
+ public static int StepUpMaximumMinutes = 480;
+
+ ///
+ /// ADP migration worker: maximum departments whose night runs in one sweep
+ /// (BackOffice-adjustable). Executions are SEQUENTIAL within the sweep — this caps how many
+ /// departments a sweep picks up, it does not parallelize them.
+ ///
+ public static int MigrationNightlyConcurrency = 1;
+
+ ///
+ /// Operator kill switch: true stops the worker from opening NEW migration windows. It never
+ /// interrupts an in-flight batch and never touches durable state, active protection, or
+ /// queued departments (plan section 19.2).
+ ///
+ public static bool MigrationQueuePaused = false;
+
+ /// Rows per transactional migration batch (cursor advances once per batch).
+ public static int MigrationBatchSize = 500;
+
+ ///
+ /// Measured migration throughput in rows/second for the sizing estimate (plan section 18.2).
+ /// Re-measured per deployment by the synthetic benchmark against production-equivalent
+ /// hardware; the conservative default stands in until then.
+ ///
+ public static int MigrationBenchmarkRowsPerSecond = 200;
+
+ /// Fixed per-table overhead added to the estimate, in seconds.
+ public static int MigrationEstimatePerTableOverheadSeconds = 30;
+
+ /// Verification-pass allowance as a fraction of the migration time (0.25 = +25%).
+ public static double MigrationEstimateVerificationAllowance = 0.25;
+
+ /// P90 multiplier over the P50 estimate — the range shown instead of false precision.
+ public static double MigrationEstimateP90Multiplier = 2.0;
+
+ /// Default department-local overnight migration window start ("HH:mm").
+ public static string MigrationWindowDefaultStartLocal = "22:00";
+
+ /// Default department-local overnight migration window end ("HH:mm").
+ public static string MigrationWindowDefaultEndLocal = "06:00";
+
+ /// Worker heartbeat interval for the department operation lock, in seconds.
+ public static int LockHeartbeatIntervalSeconds = 60;
+
+ /// Safety-valve lifetime added to each heartbeat; a stale lock stops enforcing after this.
+ public static int LockExpirySeconds = 300;
+
+ /// BackOffice protected-support grant lifetime in minutes (absolute, non-renewable).
+ public static int BackofficeProtectedSupportWindowMinutes = 5;
+
+ /// Hard maximum for BackofficeProtectedSupportWindowMinutes.
+ public static int BackofficeProtectedSupportWindowMaximumMinutes = 15;
+ }
+}
diff --git a/Core/Resgrid.Config/ExternalErrorConfig.cs b/Core/Resgrid.Config/ExternalErrorConfig.cs
index fb3cb0296..729050b26 100644
--- a/Core/Resgrid.Config/ExternalErrorConfig.cs
+++ b/Core/Resgrid.Config/ExternalErrorConfig.cs
@@ -23,6 +23,7 @@ public static class ExternalErrorConfig
public static string ExternalErrorServiceUrlForInternalWorker = "";
public static string ExternalErrorServiceUrlForMcp = "";
public static string ExternalErrorServiceUrlForTts = "";
+ public static string ExternalErrorServiceUrlForBroker = "";
public static double SentryPerfSampleRate = 0.4;
public static double SentryProfilingSampleRate = 0;
#endregion Sentry Settings
diff --git a/Core/Resgrid.Config/PaymentProviderConfig.cs b/Core/Resgrid.Config/PaymentProviderConfig.cs
index b8d69c38c..96bd5d661 100644
--- a/Core/Resgrid.Config/PaymentProviderConfig.cs
+++ b/Core/Resgrid.Config/PaymentProviderConfig.cs
@@ -36,6 +36,12 @@ public static class PaymentProviderConfig
public static string PaddleTestBillingWebhookSigningKey = "";
public static string PaddlePTT10UserAddonPackage = "";
public static string PaddlePTT10UserAddonPackageTest = "";
+
+ // Advanced Data Protection yearly addon (Paddle product pro_01m11vjn9cjmgmwzgv2kt8wndk).
+ // The Stripe side lives on the PlanAddons row (M0126); Paddle price ids follow the PTT
+ // precedent and live here.
+ public static string PaddleAdpAddon = "pri_01m11vm50c17z0rxcgy4fppf80";
+ public static string PaddleAdpAddonTest = "";
public static string PaddleProductionEnvironment = "production";
public static string PaddleTestEnvironment = "sandbox";
public static string PaddleProductionClientToken = "";
@@ -139,6 +145,14 @@ public static string GetPaddlePTT10UserAddonPackageId()
return PaddlePTT10UserAddonPackage;
}
+ public static string GetPaddleAdpAddonPriceId()
+ {
+ if (IsTestMode)
+ return PaddleAdpAddonTest;
+ else
+ return PaddleAdpAddon;
+ }
+
public static string GetPaddleEnvironment()
{
if (IsTestMode)
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx
index 91a8aaa9f..55d82b2c0 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.ar.resx
@@ -331,4 +331,24 @@
Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.Command App LoginControls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
+ Advanced Data Protection
+ These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown.
+ Manage Data Protection Settings
+ Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member.
+ View Protected Call Data
+ Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch.
+ Edit Protected Call Data
+ Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow.
+ View Protected Personnel Data
+ Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins.
+ View Protected Contact Data
+ Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins.
+ View Protected Operational Data
+ Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins.
+ Export Protected Data
+ Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered.
+ Configure Protected Data Delivery
+ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered.
+ Emergency Break-Glass Access
+ Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx
index bf680f32d..3b6635afd 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.de.resx
@@ -933,4 +933,24 @@
Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.Command App LoginControls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
+ Advanced Data Protection
+ These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown.
+ Manage Data Protection Settings
+ Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member.
+ View Protected Call Data
+ Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch.
+ Edit Protected Call Data
+ Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow.
+ View Protected Personnel Data
+ Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins.
+ View Protected Contact Data
+ Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins.
+ View Protected Operational Data
+ Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins.
+ Export Protected Data
+ Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered.
+ Configure Protected Data Delivery
+ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered.
+ Emergency Break-Glass Access
+ Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx
index 28c4d9204..b101348e1 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.el.resx
@@ -1,4 +1,4 @@
-
+
text/microsoft-resx2.0
@@ -378,6 +378,26 @@
Ελέγχει ποιος μπορεί να συνδέεται στην εφαρμογή Dispatch. Το Dispatch εμφανίζει ιδιωτικές επικοινωνίες διοίκησης, μονάδων και ανταποκριτών για κάθε περιστατικό, οπότε περιορίστε το αν τα μέλη σας δεν είναι όλα διαβιβαστές.Σύνδεση στην Εφαρμογή CommandΕλέγχει ποιος μπορεί να ενεργεί ως διοικητής: να συνδέεται στην εφαρμογή IC, να εγκαθιστά διοίκηση περιστατικού σε μια κλήση και να βλέπει πίνακες διοίκησης. Ο περιορισμός αυτού πέρα από το «Όλοι» επιτρέπει επίσης στα άτομα που επιλέγετε να βοηθούν στη λειτουργία οποιουδήποτε πίνακα διοίκησης (ανάθεση και μετακίνηση πόρων, εκτέλεση χρονομέτρων και λογοδοσίας) χωρίς να κατέχουν θέση ICS σε αυτόν — χρήσιμο για να βοηθούν οι διαβιβαστές στην εφαρμογή Dispatch. Όσο είναι ορισμένο σε «Όλοι», οι ενέργειες στον πίνακα παραμένουν περιορισμένες στον διοικητή περιστατικού και στους ανατεθειμένους ρόλους ICS.
+ Advanced Data Protection
+ These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown.
+ Manage Data Protection Settings
+ Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member.
+ View Protected Call Data
+ Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch.
+ Edit Protected Call Data
+ Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow.
+ View Protected Personnel Data
+ Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins.
+ View Protected Contact Data
+ Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins.
+ View Protected Operational Data
+ Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins.
+ Export Protected Data
+ Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered.
+ Configure Protected Data Delivery
+ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered.
+ Emergency Break-Glass Access
+ Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx
index c479a1ec1..efd4f3159 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.en.resx
@@ -378,6 +378,26 @@
Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.Command App LoginControls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
+ Advanced Data Protection
+ These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown.
+ Manage Data Protection Settings
+ Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member.
+ View Protected Call Data
+ Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch.
+ Edit Protected Call Data
+ Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow.
+ View Protected Personnel Data
+ Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins.
+ View Protected Contact Data
+ Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins.
+ View Protected Operational Data
+ Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins.
+ Export Protected Data
+ Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered.
+ Configure Protected Data Delivery
+ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered.
+ Emergency Break-Glass Access
+ Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx
index b9c0f0848..5a2b62d40 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.es.resx
@@ -337,6 +337,26 @@
Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.Command App LoginControls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
+ Advanced Data Protection
+ These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown.
+ Manage Data Protection Settings
+ Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member.
+ View Protected Call Data
+ Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch.
+ Edit Protected Call Data
+ Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow.
+ View Protected Personnel Data
+ Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins.
+ View Protected Contact Data
+ Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins.
+ View Protected Operational Data
+ Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins.
+ Export Protected Data
+ Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered.
+ Configure Protected Data Delivery
+ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered.
+ Emergency Break-Glass Access
+ Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx
index 63c0a2db6..7e430b908 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.fr.resx
@@ -933,4 +933,24 @@
Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.Command App LoginControls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
+ Advanced Data Protection
+ These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown.
+ Manage Data Protection Settings
+ Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member.
+ View Protected Call Data
+ Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch.
+ Edit Protected Call Data
+ Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow.
+ View Protected Personnel Data
+ Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins.
+ View Protected Contact Data
+ Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins.
+ View Protected Operational Data
+ Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins.
+ Export Protected Data
+ Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered.
+ Configure Protected Data Delivery
+ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered.
+ Emergency Break-Glass Access
+ Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx
index 51fb0b16e..a6a8ff0e7 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.it.resx
@@ -933,4 +933,24 @@
Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.Command App LoginControls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
+ Advanced Data Protection
+ These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown.
+ Manage Data Protection Settings
+ Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member.
+ View Protected Call Data
+ Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch.
+ Edit Protected Call Data
+ Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow.
+ View Protected Personnel Data
+ Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins.
+ View Protected Contact Data
+ Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins.
+ View Protected Operational Data
+ Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins.
+ Export Protected Data
+ Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered.
+ Configure Protected Data Delivery
+ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered.
+ Emergency Break-Glass Access
+ Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx
index 3c04894a2..7866e83ea 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.pl.resx
@@ -933,4 +933,24 @@
Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.Command App LoginControls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
+ Advanced Data Protection
+ These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown.
+ Manage Data Protection Settings
+ Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member.
+ View Protected Call Data
+ Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch.
+ Edit Protected Call Data
+ Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow.
+ View Protected Personnel Data
+ Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins.
+ View Protected Contact Data
+ Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins.
+ View Protected Operational Data
+ Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins.
+ Export Protected Data
+ Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered.
+ Configure Protected Data Delivery
+ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered.
+ Emergency Break-Glass Access
+ Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx
index b94890373..b8f0659bb 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.sv.resx
@@ -933,4 +933,24 @@
Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.Command App LoginControls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
+ Advanced Data Protection
+ These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown.
+ Manage Data Protection Settings
+ Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member.
+ View Protected Call Data
+ Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch.
+ Edit Protected Call Data
+ Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow.
+ View Protected Personnel Data
+ Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins.
+ View Protected Contact Data
+ Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins.
+ View Protected Operational Data
+ Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins.
+ Export Protected Data
+ Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered.
+ Configure Protected Data Delivery
+ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered.
+ Emergency Break-Glass Access
+ Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered.
diff --git a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx
index 8c9225f1e..0f0e26d8e 100644
--- a/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx
+++ b/Core/Resgrid.Localization/Areas/User/Security/Security.uk.resx
@@ -933,4 +933,24 @@
Controls who can sign in to the Dispatch app. Dispatch shows private command, unit and responder communications for every incident, so restrict this if your members are not all dispatchers.Command App LoginControls who can act as a commander: sign in to the IC app, establish incident command on a call, and view command boards. Narrowing this beyond Everyone also lets the people you pick help work any command board (assign and move resources, run timers and accountability) without holding an ICS position on it — useful for giving dispatchers a hand in the Dispatch app. While set to Everyone, board actions stay limited to the incident commander and assigned ICS roles.
+ Advanced Data Protection
+ These permissions control who may work with encrypted (protected) data when the Advanced Data Protection addon is active. Every reveal or edit additionally requires a recent two-factor verification; these settings choose who may even attempt it. Unlike most Resgrid permissions, unset values default to the restrictive selection shown.
+ Manage Data Protection Settings
+ Who can change Advanced Data Protection settings such as the verification window and notification content options. Purchasing, enrollment and cancellation always remain restricted to the department managing member.
+ View Protected Call Data
+ Who can reveal protected call fields (nature, address, contact info, notes) after two-factor verification. Defaults to Everyone because responding personnel must be able to read a dispatch.
+ Edit Protected Call Data
+ Who can edit protected call fields after two-factor verification. Defaults to Everyone to match the normal call workflow.
+ View Protected Personnel Data
+ Who can reveal protected personnel information (employee IDs, emergency contacts) after two-factor verification. Defaults to Department Admins.
+ View Protected Contact Data
+ Who can reveal protected contact information (names, phone numbers, government IDs, locations) after two-factor verification. Defaults to Department Admins.
+ View Protected Operational Data
+ Who can reveal protected operational content (logs, form submissions, incident command notes and attachments) after two-factor verification. Defaults to Department and Group Admins.
+ Export Protected Data
+ Who can export data containing protected fields. Exports leave the protection of Resgrid, so every export is separately audited. Defaults to Department Admins; Everyone is deliberately not offered.
+ Configure Protected Data Delivery
+ Who can change how protected content leaves Resgrid over push, SMS, email and voice. Defaults to Department Admins; Everyone is deliberately not offered.
+ Emergency Break-Glass Access
+ Who may use the audited emergency access path for protected data. It only works if break-glass is enabled in the department protection policy, requires a recorded reason, and notifies the department. Defaults to Department Admins; Everyone is deliberately not offered.
diff --git a/Core/Resgrid.Model/AdpBulkFieldRow.cs b/Core/Resgrid.Model/AdpBulkFieldRow.cs
new file mode 100644
index 000000000..53b178702
--- /dev/null
+++ b/Core/Resgrid.Model/AdpBulkFieldRow.cs
@@ -0,0 +1,23 @@
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ /// One row fetched by the ADP bulk repository: the stringified primary key plus the raw column values.
+ public sealed class AdpBulkFieldRow
+ {
+ /// Primary key rendered invariantly as a string — also the AAD row key.
+ public string RowKey { get; set; }
+
+ /// Raw values keyed by column name (string, byte[], decimal, bool, or null).
+ public Dictionary Values { get; set; } = new Dictionary();
+ }
+
+ /// One row's column updates to apply in a transactional batch.
+ public sealed class AdpBulkRowUpdate
+ {
+ public string RowKey { get; set; }
+
+ /// New values keyed by column name; a null value writes SQL NULL.
+ public Dictionary SetValues { get; set; } = new Dictionary();
+ }
+}
diff --git a/Core/Resgrid.Model/AdpEnrollmentPreflight.cs b/Core/Resgrid.Model/AdpEnrollmentPreflight.cs
new file mode 100644
index 000000000..814b4b9e2
--- /dev/null
+++ b/Core/Resgrid.Model/AdpEnrollmentPreflight.cs
@@ -0,0 +1,31 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Value-free readiness report for the Enrollment Wizard's preflight step (plan section 18.1
+ /// step 4). ADVISORY ONLY: every one of these is re-verified server-side inside
+ /// QueueEnrollmentAsync at commit time — a stale or forged preflight can never queue an
+ /// enrollment. Host-level checks (broker reachability, managing-member MFA enrollment) are
+ /// layered on by the caller; this type carries only what the protection service itself can
+ /// answer.
+ ///
+ public class AdpEnrollmentPreflight
+ {
+ /// Caller is Department.ManagingUserId (the only identity that may enroll).
+ public bool IsManagingMember { get; set; }
+
+ /// Department is on a paid plan.
+ public bool HasPaidPlan { get; set; }
+
+ /// An active, non-cancelled ADP addon exists for the department.
+ public bool HasActiveAddon { get; set; }
+
+ /// The global admission gate evaluated open (fresh, bypass-cache read).
+ public bool GateOpen { get; set; }
+
+ /// Durable state is Disabled — the only state a new enrollment may start from.
+ public bool StateAllowsEnrollment { get; set; }
+
+ /// True when every service-level check above passed.
+ public bool Passed => IsManagingMember && HasPaidPlan && HasActiveAddon && GateOpen && StateAllowsEnrollment;
+ }
+}
diff --git a/Core/Resgrid.Model/AdpMigrationNightContext.cs b/Core/Resgrid.Model/AdpMigrationNightContext.cs
new file mode 100644
index 000000000..835e03c0a
--- /dev/null
+++ b/Core/Resgrid.Model/AdpMigrationNightContext.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Everything the migration engine needs to run one department's nightly window. The coordinator
+ /// owns the department operation lock; the engine calls at least once
+ /// per batch so the lock's safety valve keeps sliding while real work happens, and stops at
+ /// by checkpointing, never mid-batch.
+ ///
+ public sealed class AdpMigrationNightContext
+ {
+ public int DepartmentId { get; set; }
+
+ /// DepartmentDataProtectionMigrationKind value for this run.
+ public DepartmentDataProtectionMigrationKind Kind { get; set; }
+
+ /// Catalog version this run migrates to.
+ public int CatalogVersion { get; set; }
+
+ /// Target department key version for enrollment/rotation; null for offboarding.
+ public int? TargetKeyVersion { get; set; }
+
+ /// UTC instant the department's overnight window closes.
+ public DateTime WindowEndUtc { get; set; }
+
+ /// The active DepartmentOperationLocks row id held by the coordinator.
+ public int DepartmentOperationLockId { get; set; }
+
+ /// Advances the lock heartbeat; the engine must invoke it at least once per batch.
+ public Func HeartbeatAsync { get; set; }
+
+ /// Correlation id threaded through migration rows, audit and notifications.
+ public string CorrelationId { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/AdpMigrationNightOutcome.cs b/Core/Resgrid.Model/AdpMigrationNightOutcome.cs
new file mode 100644
index 000000000..7da90a65c
--- /dev/null
+++ b/Core/Resgrid.Model/AdpMigrationNightOutcome.cs
@@ -0,0 +1,17 @@
+namespace Resgrid.Model
+{
+ ///
+ /// How one nightly ADP migration window ended for a department.
+ ///
+ public enum AdpMigrationNightOutcome
+ {
+ /// Every cataloged table's cursor reached the end; the run is ready for verification.
+ CompletedAllTables = 1,
+
+ /// The window closed first; cursors are checkpointed and work resumes next night.
+ WindowClosed = 2,
+
+ /// Unrecoverable batch error; the run is Failed at its last durable cursor.
+ Failed = 3
+ }
+}
diff --git a/Core/Resgrid.Model/AdpMigrationNightResult.cs b/Core/Resgrid.Model/AdpMigrationNightResult.cs
new file mode 100644
index 000000000..19ef1641d
--- /dev/null
+++ b/Core/Resgrid.Model/AdpMigrationNightResult.cs
@@ -0,0 +1,41 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Result of one department's nightly migration window. Error codes are value-free machine codes
+ /// (never exception text or content) — they land in DepartmentDataProtectionMigrations.LastErrorCode,
+ /// notifications, and the BackOffice attention view.
+ ///
+ public sealed class AdpMigrationNightResult
+ {
+ public AdpMigrationNightOutcome Outcome { get; set; }
+
+ /// Value-free error code when Outcome is Failed.
+ public string ErrorCode { get; set; }
+
+ /// Rows processed across all tables this night (progress reporting only).
+ public long RowsProcessed { get; set; }
+
+ /// Percent complete across the whole run after this night, 0-100, when known.
+ public int? PercentComplete { get; set; }
+
+ public static AdpMigrationNightResult Completed(long rowsProcessed = 0) => new AdpMigrationNightResult
+ {
+ Outcome = AdpMigrationNightOutcome.CompletedAllTables,
+ RowsProcessed = rowsProcessed,
+ PercentComplete = 100
+ };
+
+ public static AdpMigrationNightResult WindowClosed(long rowsProcessed, int? percentComplete) => new AdpMigrationNightResult
+ {
+ Outcome = AdpMigrationNightOutcome.WindowClosed,
+ RowsProcessed = rowsProcessed,
+ PercentComplete = percentComplete
+ };
+
+ public static AdpMigrationNightResult Failed(string errorCode) => new AdpMigrationNightResult
+ {
+ Outcome = AdpMigrationNightOutcome.Failed,
+ ErrorCode = errorCode
+ };
+ }
+}
diff --git a/Core/Resgrid.Model/AdpPermissionDefaults.cs b/Core/Resgrid.Model/AdpPermissionDefaults.cs
new file mode 100644
index 000000000..350de54ca
--- /dev/null
+++ b/Core/Resgrid.Model/AdpPermissionDefaults.cs
@@ -0,0 +1,55 @@
+using System;
+
+namespace Resgrid.Model
+{
+ ///
+ /// No-row defaults for the Advanced Data Protection permissions (PermissionTypes 31-39).
+ ///
+ /// Resgrid's permission convention is "a missing Permission row means allowed"
+ /// (IPermissionsService.IsUserAllowed returns true on null) — wide open. That is the WRONG
+ /// default for protected data, so every ADP authorization check MUST resolve a missing row
+ /// through this map instead of the null-allow convention, and the Security & Permissions
+ /// admin page preselects the same values so what admins see matches what is enforced.
+ ///
+ /// Rationale per value:
+ /// - View/Edit protected CALL data default to Everyone: responding personnel must be able to
+ /// read the nature and address of a dispatch or the response function breaks. The step-up
+ /// MFA grant still gates every reveal — "Everyone" here means every member who passes MFA,
+ /// never anonymous width.
+ /// - Protected PERSONNEL and CONTACT data (PII: employee IDs, emergency contacts, government
+ /// IDs) default to department admins.
+ /// - Protected OPERATIONAL data (logs, forms, IC content) defaults to department and group
+ /// admins — command staff read it, the general roster does not, and departments widen it
+ /// per role as needed.
+ /// - Export, egress configuration, break-glass, and ADP settings management default to
+ /// department admins. Break-glass additionally requires the department's policy to enable
+ /// it at all (plan section 12) — the permission alone is never sufficient.
+ ///
+ public static class AdpPermissionDefaults
+ {
+ public static PermissionActions For(PermissionTypes type)
+ {
+ switch (type)
+ {
+ case PermissionTypes.ViewProtectedCallData:
+ case PermissionTypes.EditProtectedCallData:
+ return PermissionActions.Everyone;
+
+ case PermissionTypes.ViewProtectedOperationalData:
+ return PermissionActions.DepartmentAndGroupAdmins;
+
+ case PermissionTypes.ManageDepartmentDataProtection:
+ case PermissionTypes.ViewProtectedPersonnelData:
+ case PermissionTypes.ViewProtectedContactData:
+ case PermissionTypes.ExportProtectedData:
+ case PermissionTypes.ConfigureProtectedDataEgress:
+ case PermissionTypes.BreakGlassProtectedData:
+ return PermissionActions.DepartmentAdminsOnly;
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(type),
+ $"{type} is not an Advanced Data Protection permission; use the standard permission evaluation.");
+ }
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/AdpSizingResult.cs b/Core/Resgrid.Model/AdpSizingResult.cs
new file mode 100644
index 000000000..62322e393
--- /dev/null
+++ b/Core/Resgrid.Model/AdpSizingResult.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Result of the read-only ADP sizing scan (plan section 18.2): per-table row counts and a
+ /// P50–P90 duration range with the projected number of overnight windows — never a single
+ /// false-precision number. Contains counts only; the scan touches no plaintext content.
+ ///
+ public sealed class AdpSizingResult
+ {
+ public int DepartmentId { get; set; }
+
+ public DateTime ScannedOnUtc { get; set; }
+
+ /// Department-owned rows per cataloged table.
+ public Dictionary TableRowCounts { get; set; } = new Dictionary();
+
+ public long TotalRows { get; set; }
+
+ /// Benchmark throughput (rows/second) the estimate was computed with.
+ public int BenchmarkRowsPerSecond { get; set; }
+
+ public int EstimatedP50Minutes { get; set; }
+
+ public int EstimatedP90Minutes { get; set; }
+
+ /// Projected number of nightly windows at the given window length, from the P90 estimate.
+ public int ProjectedNights { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/AdpTableBinding.cs b/Core/Resgrid.Model/AdpTableBinding.cs
new file mode 100644
index 000000000..5b8d213b5
--- /dev/null
+++ b/Core/Resgrid.Model/AdpTableBinding.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ ///
+ /// How the ADP bulk repository addresses one cataloged table: primary key, department-ownership
+ /// scope, and the cataloged columns with their storage kinds. Bindings are code-reviewed
+ /// constants defined next to the migration engine — table and column names NEVER come from
+ /// runtime input, which is what makes the repository's dynamic SQL safe.
+ ///
+ public sealed record AdpTableBinding
+ {
+ public AdpTableBinding(string tableName, string pkColumn, bool pkIsNumeric,
+ string departmentColumn, string parentFkColumn, string parentTable, string parentPkColumn,
+ IReadOnlyList columns)
+ {
+ if (string.IsNullOrWhiteSpace(tableName))
+ throw new ArgumentException("Table name is required.", nameof(tableName));
+ if (string.IsNullOrWhiteSpace(pkColumn))
+ throw new ArgumentException("Primary key column is required.", nameof(pkColumn));
+ if (string.IsNullOrWhiteSpace(departmentColumn) && string.IsNullOrWhiteSpace(parentFkColumn))
+ throw new ArgumentException("A binding needs a department column or a parent join.", nameof(departmentColumn));
+
+ TableName = tableName;
+ PkColumn = pkColumn;
+ PkIsNumeric = pkIsNumeric;
+ DepartmentColumn = departmentColumn;
+ ParentFkColumn = parentFkColumn;
+ ParentTable = parentTable;
+ ParentPkColumn = parentPkColumn;
+ Columns = columns ?? Array.Empty();
+ }
+
+ /// Direct-scope binding: the table carries its own DepartmentId column.
+ public static AdpTableBinding Direct(string tableName, string pkColumn, bool pkIsNumeric,
+ string departmentColumn, IReadOnlyList columns) =>
+ new AdpTableBinding(tableName, pkColumn, pkIsNumeric, departmentColumn, null, null, null, columns);
+
+ ///
+ /// Parent-join binding: ownership derives from a verified parent
+ /// (fk IN (SELECT parentPk FROM parentTable WHERE DepartmentId = @DepartmentId)).
+ ///
+ public static AdpTableBinding ViaParent(string tableName, string pkColumn, bool pkIsNumeric,
+ string parentFkColumn, string parentTable, string parentPkColumn, IReadOnlyList columns) =>
+ new AdpTableBinding(tableName, pkColumn, pkIsNumeric, null, parentFkColumn, parentTable, parentPkColumn, columns);
+
+ public string TableName { get; }
+ public string PkColumn { get; }
+ public bool PkIsNumeric { get; }
+
+ /// Department column when the table is directly scoped; null for parent-join bindings.
+ public string DepartmentColumn { get; }
+
+ public string ParentFkColumn { get; }
+ public string ParentTable { get; }
+ public string ParentPkColumn { get; }
+
+ public IReadOnlyList Columns { get; }
+
+ /// Row-level protection marker column ("IsProtected"), when the table has one (companion pattern).
+ public string ProtectedMarkerColumn { get; init; }
+ }
+
+ /// One cataloged column inside a binding.
+ public sealed class AdpColumnSpec
+ {
+ public AdpColumnSpec(string columnName, string fieldId, ProtectedFieldStorageKind storageKind, string companionColumn = null)
+ {
+ ColumnName = columnName;
+ FieldId = fieldId;
+ StorageKind = storageKind;
+ CompanionColumn = companionColumn;
+
+ if (storageKind == ProtectedFieldStorageKind.CompanionColumn && string.IsNullOrWhiteSpace(companionColumn))
+ throw new ArgumentException("Companion storage requires a companion column name.", nameof(companionColumn));
+ }
+
+ public string ColumnName { get; }
+
+ /// Stable catalog field id — the AAD component.
+ public string FieldId { get; }
+
+ public ProtectedFieldStorageKind StorageKind { get; }
+
+ /// Envelope column for CompanionColumn storage (e.g. "ProtectedLatitudeEnvelope").
+ public string CompanionColumn { get; }
+ }
+}
diff --git a/Core/Resgrid.Model/CallAttachment.cs b/Core/Resgrid.Model/CallAttachment.cs
index 1c8451508..5bc71018c 100644
--- a/Core/Resgrid.Model/CallAttachment.cs
+++ b/Core/Resgrid.Model/CallAttachment.cs
@@ -69,6 +69,18 @@ public class CallAttachment: IEntity
public DateTime? DeletedOn { get; set; }
+ /// ADP: true when this row's cataloged values carry rgdp envelopes (M0128).
+ [ProtoMember(11)]
+ public bool IsProtected { get; set; }
+
+ /// ADP companion column: envelope for Latitude while protected; typed column is nulled.
+ [ProtoMember(12)]
+ public string ProtectedLatitudeEnvelope { get; set; }
+
+ /// ADP companion column: envelope for Longitude while protected; typed column is nulled.
+ [ProtoMember(13)]
+ public string ProtectedLongitudeEnvelope { get; set; }
+
[NotMapped]
[JsonIgnore]
public object IdValue
diff --git a/Core/Resgrid.Model/CallNote.cs b/Core/Resgrid.Model/CallNote.cs
index 4b5063c03..582b8a973 100644
--- a/Core/Resgrid.Model/CallNote.cs
+++ b/Core/Resgrid.Model/CallNote.cs
@@ -63,6 +63,18 @@ public class CallNote : IEntity
public DateTime? DeletedOn { get; set; }
+ /// ADP: true when this row's cataloged values carry rgdp envelopes (M0128).
+ [ProtoMember(10)]
+ public bool IsProtected { get; set; }
+
+ /// ADP companion column: envelope for Latitude while protected; typed column is nulled.
+ [ProtoMember(11)]
+ public string ProtectedLatitudeEnvelope { get; set; }
+
+ /// ADP companion column: envelope for Longitude while protected; typed column is nulled.
+ [ProtoMember(12)]
+ public string ProtectedLongitudeEnvelope { get; set; }
+
[NotMapped]
[JsonIgnore]
public object IdValue
diff --git a/Core/Resgrid.Model/DepartmentDataProtectionEnrollmentResult.cs b/Core/Resgrid.Model/DepartmentDataProtectionEnrollmentResult.cs
new file mode 100644
index 000000000..c9a9bad37
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentDataProtectionEnrollmentResult.cs
@@ -0,0 +1,37 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Server-side outcome of an ADP enrollment (or offboarding-control) command. Denial values map to
+ /// the value-free problem codes the API returns: addon_required, feature_not_available,
+ /// plan_required, protected_access_denied.
+ ///
+ public enum DepartmentDataProtectionEnrollmentResult
+ {
+ Queued = 1,
+
+ /// The department's durable state does not permit this command.
+ InvalidState = 2,
+
+ /// Caller is not Department.ManagingUserId; ordinary admins cannot run ADP billing/enrollment commands.
+ NotManagingMember = 3,
+
+ /// No active paid ADP addon for the department (addon_required).
+ AddonRequired = 4,
+
+ /// The global admission gate evaluated false/missing/error (feature_not_available).
+ FeatureNotAvailable = 5,
+
+ /// Department is on the free plan (plan_required).
+ PlanRequired = 6,
+
+ /// Transient/internal failure; the command may be retried.
+ Failed = 7,
+
+ ///
+ /// The migration window is unusable — no resolvable time zone was supplied and the
+ /// department has none. Queuing anyway would stall forever: the worker reads an
+ /// unresolvable window time zone as permanently closed (invalid_window).
+ ///
+ InvalidWindow = 8
+ }
+}
diff --git a/Core/Resgrid.Model/DepartmentDataProtectionKey.cs b/Core/Resgrid.Model/DepartmentDataProtectionKey.cs
new file mode 100644
index 000000000..3a61bf524
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentDataProtectionKey.cs
@@ -0,0 +1,77 @@
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// One version of a department's data encryption key (DEK) in its KMS-wrapped form. NEVER holds
+ /// plaintext key material — the wrapped blob is only unwrapped inside the Protected Data Broker via
+ /// the KMS wrap/unwrap API with the department encryption context. Rows are never deleted by
+ /// ordinary rotation or offboarding; cryptographic erasure is a separate dual-controlled operation.
+ /// Deliberately not cached in Redis and not protobuf cache-serializable.
+ ///
+ [Table("DepartmentDataProtectionKeys")]
+ public class DepartmentDataProtectionKey : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int DepartmentDataProtectionKeyId { get; set; }
+
+ [Required]
+ public int DepartmentId { get; set; }
+
+ /// Department key version referenced by the rgdp envelope header. Starts at 1.
+ [Required]
+ public int Version { get; set; }
+
+ /// Base64 KMS-wrapped DEK as returned by the wrapping provider (e.g. Transit datakey/wrapped).
+ [Required]
+ public string WrappedKey { get; set; }
+
+ /// Wrapping provider discriminator (e.g. "OpenBaoTransit", "AzureKeyVault", "AwsKms", "LocalDev").
+ [Required]
+ [MaxLength(64)]
+ public string ProviderType { get; set; }
+
+ /// Provider key reference — for OpenBao Transit: mount and key name (e.g. "transit/resgrid-dept-kek").
+ [Required]
+ [MaxLength(256)]
+ public string ProviderKeyReference { get; set; }
+
+ /// KEK version at the provider that wrapped this DEK (Transit key version for rewrap tracking).
+ public int ProviderKeyVersion { get; set; }
+
+ /// DepartmentDataProtectionKeyStatus value.
+ public int Status { get; set; }
+
+ public DateTime CreatedOn { get; set; }
+
+ public DateTime? ActivatedOn { get; set; }
+
+ public DateTime? RetiredOn { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return DepartmentDataProtectionKeyId; }
+ set { DepartmentDataProtectionKeyId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "DepartmentDataProtectionKeys";
+
+ [NotMapped]
+ public string IdName => "DepartmentDataProtectionKeyId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/DepartmentDataProtectionKeyStatus.cs b/Core/Resgrid.Model/DepartmentDataProtectionKeyStatus.cs
new file mode 100644
index 000000000..351b6194c
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentDataProtectionKeyStatus.cs
@@ -0,0 +1,17 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Lifecycle status of one wrapped department data encryption key (DEK) version in
+ /// DepartmentDataProtectionKeys. New writes always use the single Active version; Retiring versions
+ /// remain resolvable for reads until rotation re-encryption completes, after which they become
+ /// Retired. Ordinary offboarding never deletes key rows — cryptographic erasure is a separate
+ /// dual-controlled retention operation.
+ ///
+ public enum DepartmentDataProtectionKeyStatus
+ {
+ Pending = 0,
+ Active = 1,
+ Retiring = 2,
+ Retired = 3
+ }
+}
diff --git a/Core/Resgrid.Model/DepartmentDataProtectionMigration.cs b/Core/Resgrid.Model/DepartmentDataProtectionMigration.cs
new file mode 100644
index 000000000..9abdd3487
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentDataProtectionMigration.cs
@@ -0,0 +1,97 @@
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Durable cursor and progress record for one table of one department's bulk ADP migration
+ /// (enrollment encryption, offboarding decryption, or rotation re-encryption). The ADP migration
+ /// worker checkpoints the cursor in the same transaction as each batch's writes, so a crash
+ /// re-processes at most one batch — which the double-encryption guard makes a no-op. Error codes
+ /// are value-free; no plaintext, ciphertext or key material is ever recorded here.
+ ///
+ [Table("DepartmentDataProtectionMigrations")]
+ public class DepartmentDataProtectionMigration : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int DepartmentDataProtectionMigrationId { get; set; }
+
+ [Required]
+ public int DepartmentId { get; set; }
+
+ /// DepartmentDataProtectionMigrationKind value.
+ [Required]
+ public int Kind { get; set; }
+
+ /// Protected-field catalog version this run migrates to.
+ [Required]
+ public int CatalogVersion { get; set; }
+
+ /// Target department key version for enrollment/rotation runs; null for offboarding.
+ public int? TargetKeyVersion { get; set; }
+
+ /// Cataloged table this row tracks (one row per table per run).
+ [Required]
+ [MaxLength(128)]
+ public string TargetTable { get; set; }
+
+ /// Serialized resume cursor (last processed key of TargetTable), engine-agnostic string form.
+ [MaxLength(256)]
+ public string Cursor { get; set; }
+
+ public long RowsTotal { get; set; }
+
+ public long RowsProcessed { get; set; }
+
+ /// Rows skipped because they already carried a matching rgdp envelope (idempotent re-run).
+ public long RowsAlreadyProtected { get; set; }
+
+ /// Plaintext values seen on the decrypt path (passed through untouched) — anomaly counter.
+ public long RowsAnomalous { get; set; }
+
+ /// DepartmentDataProtectionVerificationState value.
+ public int VerificationState { get; set; }
+
+ public int Attempts { get; set; }
+
+ /// Value-free machine error code for the last failure; never exception text or content.
+ [MaxLength(64)]
+ public string LastErrorCode { get; set; }
+
+ [MaxLength(128)]
+ public string CorrelationId { get; set; }
+
+ public DateTime CreatedOn { get; set; }
+
+ public DateTime? StartedOn { get; set; }
+
+ public DateTime? CheckpointedOn { get; set; }
+
+ public DateTime? CompletedOn { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return DepartmentDataProtectionMigrationId; }
+ set { DepartmentDataProtectionMigrationId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "DepartmentDataProtectionMigrations";
+
+ [NotMapped]
+ public string IdName => "DepartmentDataProtectionMigrationId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/DepartmentDataProtectionMigrationKind.cs b/Core/Resgrid.Model/DepartmentDataProtectionMigrationKind.cs
new file mode 100644
index 000000000..6abdd0325
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentDataProtectionMigrationKind.cs
@@ -0,0 +1,15 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Direction/purpose of a bulk ADP migration run recorded in DepartmentDataProtectionMigrations.
+ /// Enrollment encrypts plaintext into rgdp envelopes, Offboarding decrypts envelopes back to
+ /// plaintext, Rotation re-encrypts under a new department key version. All three share the same
+ /// cursor, checkpoint and idempotency machinery.
+ ///
+ public enum DepartmentDataProtectionMigrationKind
+ {
+ Enrollment = 0,
+ Offboarding = 1,
+ Rotation = 2
+ }
+}
diff --git a/Core/Resgrid.Model/DepartmentDataProtectionOffboardingSource.cs b/Core/Resgrid.Model/DepartmentDataProtectionOffboardingSource.cs
new file mode 100644
index 000000000..8127cc317
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentDataProtectionOffboardingSource.cs
@@ -0,0 +1,16 @@
+namespace Resgrid.Model
+{
+ ///
+ /// What triggered scheduling of ADP offboarding for a department. UserCancelled = the managing
+ /// member cancelled the addon (offboarding at end of paid cycle, revocable until the first
+ /// offboarding window opens); DunningExhausted = payment failure dunning ran out (paid period plus
+ /// fixed grace); Chargeback = chargeback/refund treated as cancellation with immediate effective
+ /// date — offboarding still runs through the normal worker path, never an instant crypto flip.
+ ///
+ public enum DepartmentDataProtectionOffboardingSource
+ {
+ UserCancelled = 1,
+ DunningExhausted = 2,
+ Chargeback = 3
+ }
+}
diff --git a/Core/Resgrid.Model/DepartmentDataProtectionPolicy.cs b/Core/Resgrid.Model/DepartmentDataProtectionPolicy.cs
new file mode 100644
index 000000000..ba44f4125
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentDataProtectionPolicy.cs
@@ -0,0 +1,153 @@
+using Newtonsoft.Json;
+using ProtoBuf;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Durable Advanced Data Protection (ADP) policy for one department — the single data-safety truth
+ /// for protection state (see DepartmentDataProtectionState). One row per department. Billing state
+ /// and the enrollment feature flag are admission controls only and are never duplicated here as
+ /// runtime authorization; this row records only the audit reference of the successful enrollment
+ /// flag evaluation.
+ ///
+ [Table("DepartmentDataProtectionPolicies")]
+ [ProtoContract]
+ public class DepartmentDataProtectionPolicy : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ [ProtoMember(1)]
+ public int DepartmentDataProtectionPolicyId { get; set; }
+
+ [Required]
+ [ProtoMember(2)]
+ public int DepartmentId { get; set; }
+
+ /// DepartmentDataProtectionState value.
+ [ProtoMember(3)]
+ public int State { get; set; }
+
+ /// Protected-field catalog version this department is migrated to (0 = none).
+ [ProtoMember(4)]
+ public int CatalogVersion { get; set; }
+
+ ///
+ /// DepartmentDataProtectionMigrationKind value of the in-flight migration when State is a
+ /// transitional one (EnrollmentQueued..Verifying, Rotating, DisableRequested, Decrypting,
+ /// Failed); null when no migration is active. Disambiguates the shared Verifying state.
+ ///
+ [ProtoMember(5)]
+ public int? ActiveMigrationKind { get; set; }
+
+ ///
+ /// Absolute lifetime of a Protected Data Grant in minutes. Default 15; values above the warning
+ /// threshold (60) require a recorded reason; the platform enforces an operator ceiling
+ /// (initially 480). Never sliding.
+ ///
+ [ProtoMember(6)]
+ public int StepUpWindowMinutes { get; set; }
+
+ /// Recorded administrator reason required when StepUpWindowMinutes exceeds 60.
+ [ProtoMember(7)]
+ public string StepUpWindowReason { get; set; }
+
+ ///
+ /// Monotonically increasing policy version. Any change to policy, egress, membership rules or
+ /// catalog increments it and revokes previously issued grants (grants carry policy_epoch).
+ ///
+ [ProtoMember(8)]
+ public long PolicyEpoch { get; set; }
+
+ /// JSON map of application -> minimum client version allowed protected operations.
+ [ProtoMember(9)]
+ public string MinimumClientVersionsJson { get; set; }
+
+ ///
+ /// JSON record of every versioned Enrollment Wizard acknowledgement (section 12 disclosure
+ /// items), including the persisted sizing scan results and shown estimate.
+ ///
+ [ProtoMember(10)]
+ public string AcknowledgementsJson { get; set; }
+
+ [MaxLength(128)]
+ [ProtoMember(11)]
+ public string AcknowledgedByUserId { get; set; }
+
+ [ProtoMember(12)]
+ public DateTime? AcknowledgedOn { get; set; }
+
+ ///
+ /// Value-free audit reference (flag key, evaluation result/source, correlation ID) of the fresh
+ /// authoritative feature-flag evaluation performed immediately before the enrollment commit.
+ ///
+ [ProtoMember(13)]
+ public string EnrollmentFlagEvaluationJson { get; set; }
+
+ /// External billing reference (provider subscription/addon id) for the ADP addon.
+ [MaxLength(256)]
+ [ProtoMember(14)]
+ public string AddonBillingReference { get; set; }
+
+ /// Department-local overnight migration window start, "HH:mm" (default 22:00).
+ [MaxLength(5)]
+ [ProtoMember(15)]
+ public string MigrationWindowStartLocal { get; set; }
+
+ /// Department-local overnight migration window end, "HH:mm" (default 06:00).
+ [MaxLength(5)]
+ [ProtoMember(16)]
+ public string MigrationWindowEndLocal { get; set; }
+
+ /// IANA/Windows time zone id the migration window is evaluated in.
+ [MaxLength(128)]
+ [ProtoMember(17)]
+ public string MigrationWindowTimeZone { get; set; }
+
+ /// UTC instant offboarding becomes due (end of paid cycle plus any dunning grace).
+ [ProtoMember(18)]
+ public DateTime? OffboardingEffectiveOn { get; set; }
+
+ /// DepartmentDataProtectionOffboardingSource value; null when no offboarding scheduled.
+ [ProtoMember(19)]
+ public int? OffboardingSource { get; set; }
+
+ [ProtoMember(20)]
+ public DateTime CreatedOn { get; set; }
+
+ [MaxLength(128)]
+ [ProtoMember(21)]
+ public string CreatedByUserId { get; set; }
+
+ [ProtoMember(22)]
+ public DateTime? UpdatedOn { get; set; }
+
+ [MaxLength(128)]
+ [ProtoMember(23)]
+ public string UpdatedByUserId { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return DepartmentDataProtectionPolicyId; }
+ set { DepartmentDataProtectionPolicyId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "DepartmentDataProtectionPolicies";
+
+ [NotMapped]
+ public string IdName => "DepartmentDataProtectionPolicyId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/DepartmentDataProtectionState.cs b/Core/Resgrid.Model/DepartmentDataProtectionState.cs
new file mode 100644
index 000000000..bd3c487a8
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentDataProtectionState.cs
@@ -0,0 +1,31 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Durable data-safety state for a department's Advanced Data Protection (ADP) lifecycle, stored on
+ /// DepartmentDataProtectionPolicies.State. This is the ONLY data-safety truth: billing state and the
+ /// Security.DepartmentProtectedDataEnrollment feature flag are admission/commercial controls and must
+ /// never drive runtime encrypt/decrypt behavior. All transitions from EnrollmentQueued onward are made
+ /// by the ADP migration worker inside scheduled windows, never inline in Web/API requests.
+ ///
+ /// Enrollment: Disabled -> EnrollmentQueued -> ProvisioningKey -> Encrypting -> Verifying -> Enabled
+ /// Rotation: Enabled -> Rotating -> Verifying -> Enabled
+ /// Offboarding: Enabled -> OffboardingScheduled -> DisableRequested -> Decrypting -> Verifying -> Disabled
+ /// Verifying is shared by enrollment, rotation and offboarding; the direction is carried by
+ /// DepartmentDataProtectionPolicy.ActiveMigrationKind (a DepartmentDataProtectionMigrationKind value).
+ /// Failures land in the resumable Failed state with the migration cursor intact.
+ ///
+ public enum DepartmentDataProtectionState
+ {
+ Disabled = 0,
+ EnrollmentQueued = 1,
+ ProvisioningKey = 2,
+ Encrypting = 3,
+ Verifying = 4,
+ Enabled = 5,
+ Rotating = 6,
+ OffboardingScheduled = 7,
+ DisableRequested = 8,
+ Decrypting = 9,
+ Failed = 10
+ }
+}
diff --git a/Core/Resgrid.Model/DepartmentDataProtectionVerificationState.cs b/Core/Resgrid.Model/DepartmentDataProtectionVerificationState.cs
new file mode 100644
index 000000000..d130b92b9
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentDataProtectionVerificationState.cs
@@ -0,0 +1,16 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Verification progress for one DepartmentDataProtectionMigrations row. Only a Passed verification
+ /// (counts, AEAD/AAD spot checks, catalog coverage, plaintext-residue scan for enrollment or
+ /// envelope-residue scan for offboarding) lets the worker transition the department out of
+ /// Verifying.
+ ///
+ public enum DepartmentDataProtectionVerificationState
+ {
+ NotStarted = 0,
+ InProgress = 1,
+ Passed = 2,
+ Failed = 3
+ }
+}
diff --git a/Core/Resgrid.Model/DepartmentMemberSensitiveData.cs b/Core/Resgrid.Model/DepartmentMemberSensitiveData.cs
new file mode 100644
index 000000000..2aed24456
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentMemberSensitiveData.cs
@@ -0,0 +1,79 @@
+using Newtonsoft.Json;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Department-owned sensitive personnel attributes that cannot safely stay on global UserProfile
+ /// rows — a user in several departments cannot have one global row encrypted under one department
+ /// key. One row per (DepartmentId, UserId). Values are plaintext until the department enrolls in
+ /// ADP, after which cataloged columns carry rgdp envelopes; IsProtected/ProtectedCatalogVersion
+ /// track per-row protection state for the migration cursor and the double-encryption guard.
+ ///
+ [Table("DepartmentMemberSensitiveData")]
+ public class DepartmentMemberSensitiveData : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int DepartmentMemberSensitiveDataId { get; set; }
+
+ [Required]
+ public int DepartmentId { get; set; }
+
+ [Required]
+ [MaxLength(128)]
+ public string UserId { get; set; }
+
+ ///
+ /// Stable random id bound into the AAD of every envelope on this row, so ciphertext cannot be
+ /// moved between rows even inside the same department.
+ ///
+ [Required]
+ [MaxLength(64)]
+ public string ProtectionId { get; set; }
+
+ /// Department-scoped employee/member identification number (moved off UserProfile).
+ public string IdentificationNumber { get; set; }
+
+ public string EmergencyContactName { get; set; }
+
+ public string EmergencyContactPhone { get; set; }
+
+ /// Free-form department-scoped notes about the member.
+ public string Notes { get; set; }
+
+ /// True when this row's cataloged values carry rgdp envelopes.
+ public bool IsProtected { get; set; }
+
+ /// Catalog version the row was protected under; null while plaintext.
+ public int? ProtectedCatalogVersion { get; set; }
+
+ public DateTime CreatedOn { get; set; }
+
+ public DateTime? UpdatedOn { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return DepartmentMemberSensitiveDataId; }
+ set { DepartmentMemberSensitiveDataId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "DepartmentMemberSensitiveData";
+
+ [NotMapped]
+ public string IdName => "DepartmentMemberSensitiveDataId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/DepartmentOperationLock.cs b/Core/Resgrid.Model/DepartmentOperationLock.cs
new file mode 100644
index 000000000..daa0b0170
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentOperationLock.cs
@@ -0,0 +1,95 @@
+using Newtonsoft.Json;
+using ProtoBuf;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// A department-wide mutation freeze (reads continue) held by the ADP migration worker during an
+ /// active migration window. At most one active (ReleasedUtc IS NULL) lock per department, enforced
+ /// by a filtered/partial unique index. The worker heartbeats HeartbeatUtc; when the heartbeat goes
+ /// stale past ExpiresUtc the lock reports Expired and enforcement ends automatically — dispatch
+ /// availability beats migration progress.
+ ///
+ [Table("DepartmentOperationLocks")]
+ [ProtoContract]
+ public class DepartmentOperationLock : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ [ProtoMember(1)]
+ public int DepartmentOperationLockId { get; set; }
+
+ [Required]
+ [ProtoMember(2)]
+ public int DepartmentId { get; set; }
+
+ /// DepartmentOperationLockType value.
+ [Required]
+ [ProtoMember(3)]
+ public int LockType { get; set; }
+
+ /// Human-readable, value-free reason shown in client banners and BackOffice.
+ [MaxLength(512)]
+ [ProtoMember(4)]
+ public string Reason { get; set; }
+
+ [MaxLength(128)]
+ [ProtoMember(5)]
+ public string CorrelationId { get; set; }
+
+ [ProtoMember(6)]
+ public DateTime AppliedUtc { get; set; }
+
+ /// Workload/user identity that applied the lock.
+ [MaxLength(256)]
+ [ProtoMember(7)]
+ public string AppliedByIdentity { get; set; }
+
+ [ProtoMember(8)]
+ public DateTime HeartbeatUtc { get; set; }
+
+ /// Safety valve — enforcement ends automatically once past this with a stale heartbeat.
+ [ProtoMember(9)]
+ public DateTime ExpiresUtc { get; set; }
+
+ /// Projected end of the migration window, surfaced to clients in the lock banner.
+ [ProtoMember(10)]
+ public DateTime? ProjectedEndUtc { get; set; }
+
+ [ProtoMember(11)]
+ public DateTime? ReleasedUtc { get; set; }
+
+ [MaxLength(256)]
+ [ProtoMember(12)]
+ public string ReleasedBy { get; set; }
+
+ /// DepartmentOperationLockReleaseKind value; null while active.
+ [ProtoMember(13)]
+ public int? ReleaseKind { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return DepartmentOperationLockId; }
+ set { DepartmentOperationLockId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "DepartmentOperationLocks";
+
+ [NotMapped]
+ public string IdName => "DepartmentOperationLockId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/DepartmentOperationLockReleaseKind.cs b/Core/Resgrid.Model/DepartmentOperationLockReleaseKind.cs
new file mode 100644
index 000000000..e2abb74ed
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentOperationLockReleaseKind.cs
@@ -0,0 +1,16 @@
+namespace Resgrid.Model
+{
+ ///
+ /// How a DepartmentOperationLocks row was released. Completed = migration finished; Checkpoint =
+ /// nightly window closed with the cursor durably checkpointed; Aborted = managing member or operator
+ /// break-glass abort (dispatch beats migration); Expired = worker heartbeat went stale past
+ /// ExpiresUtc and enforcement ended automatically.
+ ///
+ public enum DepartmentOperationLockReleaseKind
+ {
+ Completed = 1,
+ Checkpoint = 2,
+ Aborted = 3,
+ Expired = 4
+ }
+}
diff --git a/Core/Resgrid.Model/DepartmentOperationLockType.cs b/Core/Resgrid.Model/DepartmentOperationLockType.cs
new file mode 100644
index 000000000..912ebf022
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentOperationLockType.cs
@@ -0,0 +1,11 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Reason class for a DepartmentOperationLocks row. Introduced for ADP bulk migrations but designed
+ /// as a general mechanism; add new members rather than overloading AdpMigration.
+ ///
+ public enum DepartmentOperationLockType
+ {
+ AdpMigration = 1
+ }
+}
diff --git a/Core/Resgrid.Model/DepartmentProtectedDataEgressPolicy.cs b/Core/Resgrid.Model/DepartmentProtectedDataEgressPolicy.cs
new file mode 100644
index 000000000..bd0bd7d6a
--- /dev/null
+++ b/Core/Resgrid.Model/DepartmentProtectedDataEgressPolicy.cs
@@ -0,0 +1,102 @@
+using Newtonsoft.Json;
+using ProtoBuf;
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Independent per-channel egress modes for protected content for one department (one row per
+ /// department). Every channel defaults to ProtectedDataEgressMode.GenericOnly; enabling protected
+ /// content on any channel requires an explicit versioned warning acknowledgement. Changing any
+ /// mode increments the department PolicyEpoch (on DepartmentDataProtectionPolicy), cancelling
+ /// pending protected deliveries where possible. Egress policy can never relax BigBoard or Workflow
+ /// restrictions.
+ ///
+ [Table("DepartmentProtectedDataEgressPolicies")]
+ [ProtoContract]
+ public class DepartmentProtectedDataEgressPolicy : IEntity
+ {
+ [Key]
+ [Required]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ [ProtoMember(1)]
+ public int DepartmentProtectedDataEgressPolicyId { get; set; }
+
+ [Required]
+ [ProtoMember(2)]
+ public int DepartmentId { get; set; }
+
+ /// ProtectedDataEgressMode value (ProtectedAfterPin is not valid for push).
+ [ProtoMember(3)]
+ public int PushMode { get; set; }
+
+ /// ProtectedDataEgressMode value (ProtectedAfterPin is not valid for email).
+ [ProtoMember(4)]
+ public int EmailMode { get; set; }
+
+ /// ProtectedDataEgressMode value.
+ [ProtoMember(5)]
+ public int SmsMode { get; set; }
+
+ /// ProtectedDataEgressMode value.
+ [ProtoMember(6)]
+ public int VoiceMode { get; set; }
+
+ /// PIN-release one-time challenge lifetime in minutes (default 5).
+ [ProtoMember(7)]
+ public int PinChallengeExpiryMinutes { get; set; }
+
+ /// Failed PIN attempts before lockout.
+ [ProtoMember(8)]
+ public int PinMaxAttempts { get; set; }
+
+ /// Lockout duration in minutes after PinMaxAttempts failures.
+ [ProtoMember(9)]
+ public int PinLockoutMinutes { get; set; }
+
+ /// Version identifier of the warning text the administrator acknowledged.
+ [MaxLength(64)]
+ [ProtoMember(10)]
+ public string AcknowledgementVersion { get; set; }
+
+ [MaxLength(128)]
+ [ProtoMember(11)]
+ public string AcknowledgedByUserId { get; set; }
+
+ [ProtoMember(12)]
+ public DateTime? AcknowledgedOn { get; set; }
+
+ [ProtoMember(13)]
+ public DateTime CreatedOn { get; set; }
+
+ [ProtoMember(14)]
+ public DateTime? UpdatedOn { get; set; }
+
+ [MaxLength(128)]
+ [ProtoMember(15)]
+ public string UpdatedByUserId { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get { return DepartmentProtectedDataEgressPolicyId; }
+ set { DepartmentProtectedDataEgressPolicyId = (int)value; }
+ }
+
+ [NotMapped]
+ public string TableName => "DepartmentProtectedDataEgressPolicies";
+
+ [NotMapped]
+ public string IdName => "DepartmentProtectedDataEgressPolicyId";
+
+ [NotMapped]
+ public int IdType => 0;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" };
+ }
+}
diff --git a/Core/Resgrid.Model/FeatureFlagKeys.cs b/Core/Resgrid.Model/FeatureFlagKeys.cs
index fea05bef5..fe70c2284 100644
--- a/Core/Resgrid.Model/FeatureFlagKeys.cs
+++ b/Core/Resgrid.Model/FeatureFlagKeys.cs
@@ -24,5 +24,15 @@ public static class FeatureFlagKeys
/// response, move-up recommendations) across the web UI and API. Seeded by M0116.
///
public const string DispatchRunCards = "Dispatch.RunCards";
+
+ ///
+ /// Global admission gate for Advanced Data Protection (ADP) enrollment. When true, any department
+ /// with an active paid ADP addon may enroll via the Enrollment Wizard; when false, no new enrollment
+ /// commits anywhere (operator platform-wide pause). This flag gates NEW enrollment only — it is never
+ /// consulted for runtime encrypt/decrypt behavior, grant issuance, rotation, or opt-out of a
+ /// department whose durable DepartmentDataProtectionPolicies.State is already active. Operator-managed,
+ /// seeded off and permanent by M0126; ordinary department administrators cannot see or change it.
+ ///
+ public const string DepartmentProtectedDataEnrollment = "Security.DepartmentProtectedDataEnrollment";
}
}
diff --git a/Core/Resgrid.Model/MessageRecipient.cs b/Core/Resgrid.Model/MessageRecipient.cs
index ee12d9440..98dd972ca 100644
--- a/Core/Resgrid.Model/MessageRecipient.cs
+++ b/Core/Resgrid.Model/MessageRecipient.cs
@@ -54,6 +54,18 @@ public class MessageRecipient : IEntity
[DecimalPrecision(10, 7)]
public decimal? Longitude { get; set; }
+ /// ADP: true when this row's cataloged values carry rgdp envelopes (M0129; inert until catalog v2).
+ [ProtoMember(11)]
+ public bool IsProtected { get; set; }
+
+ /// ADP companion column: envelope for Latitude while protected; typed column is nulled.
+ [ProtoMember(12)]
+ public string ProtectedLatitudeEnvelope { get; set; }
+
+ /// ADP companion column: envelope for Longitude while protected; typed column is nulled.
+ [ProtoMember(13)]
+ public string ProtectedLongitudeEnvelope { get; set; }
+
[NotMapped]
[JsonIgnore]
public object IdValue
diff --git a/Core/Resgrid.Model/PermissionTypes.cs b/Core/Resgrid.Model/PermissionTypes.cs
index 9accd3141..855e02733 100644
--- a/Core/Resgrid.Model/PermissionTypes.cs
+++ b/Core/Resgrid.Model/PermissionTypes.cs
@@ -46,7 +46,41 @@ public enum PermissionTypes
/// can be narrowed to admins, group admins, or selected personnel roles — the same ladder as
/// .
///
- CommandAppLogin = 30
+ CommandAppLogin = 30,
+
+ ///
+ /// Manage post-enrollment Advanced Data Protection settings: step-up window and egress policy.
+ /// Deliberately NOT sufficient for enrollment, offboarding, or ADP billing commands — those are
+ /// restricted server-side to Department.ManagingUserId (ADP plan decision 15).
+ ///
+ ManageDepartmentDataProtection = 31,
+
+ /// Reveal protected call/dispatch fields (with a current Protected Data Grant).
+ ViewProtectedCallData = 32,
+
+ /// Edit protected call/dispatch fields (with a current Protected Data Grant).
+ EditProtectedCallData = 33,
+
+ /// Reveal protected personnel/member fields (with a current Protected Data Grant).
+ ViewProtectedPersonnelData = 34,
+
+ /// Reveal protected contact fields (with a current Protected Data Grant).
+ ViewProtectedContactData = 35,
+
+ /// Reveal protected operational data — logs, forms/UDF, IC content, documents (with a grant).
+ ViewProtectedOperationalData = 36,
+
+ /// Export data containing protected fields; every export is separately audited.
+ ExportProtectedData = 37,
+
+ /// Configure per-channel protected-data egress (push/SMS/email/voice modes, PIN release).
+ ConfigureProtectedDataEgress = 38,
+
+ ///
+ /// Emergency break-glass access to protected data. Off by default; every use requires a reason,
+ /// produces notifications, and is subject to review (ADP plan section 12).
+ ///
+ BreakGlassProtectedData = 39
}
}
diff --git a/Core/Resgrid.Model/PlanAddon.cs b/Core/Resgrid.Model/PlanAddon.cs
index fca7d9d35..16f0853c0 100644
--- a/Core/Resgrid.Model/PlanAddon.cs
+++ b/Core/Resgrid.Model/PlanAddon.cs
@@ -98,6 +98,8 @@ public string GetAddonName()
{
case PlanAddonTypes.PTT:
return "Push-To-Talk";
+ case PlanAddonTypes.ADP:
+ return "Advanced Data Protection";
default:
throw new ArgumentOutOfRangeException();
}
diff --git a/Core/Resgrid.Model/PlanAddonTypes.cs b/Core/Resgrid.Model/PlanAddonTypes.cs
index 871f751e9..f5b96aa17 100644
--- a/Core/Resgrid.Model/PlanAddonTypes.cs
+++ b/Core/Resgrid.Model/PlanAddonTypes.cs
@@ -2,6 +2,7 @@
{
public enum PlanAddonTypes
{
- PTT = 1
+ PTT = 1,
+ ADP = 2
}
}
diff --git a/Core/Resgrid.Model/ProtectedDataEgressChannel.cs b/Core/Resgrid.Model/ProtectedDataEgressChannel.cs
new file mode 100644
index 000000000..6b94030f2
--- /dev/null
+++ b/Core/Resgrid.Model/ProtectedDataEgressChannel.cs
@@ -0,0 +1,17 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Outbound notification channel for protected-data egress decisions (ADP plan section 9). Each
+ /// maps to its DepartmentProtectedDataEgressPolicies mode column; ChatPlatform (Discord, Slack,
+ /// Telegram, ...) is third-party egress with no policy column of its own and is always generic
+ /// for a protected department.
+ ///
+ public enum ProtectedDataEgressChannel
+ {
+ Push = 1,
+ Sms = 2,
+ Email = 3,
+ Voice = 4,
+ ChatPlatform = 5
+ }
+}
diff --git a/Core/Resgrid.Model/ProtectedDataEgressMode.cs b/Core/Resgrid.Model/ProtectedDataEgressMode.cs
new file mode 100644
index 000000000..fc9edb87a
--- /dev/null
+++ b/Core/Resgrid.Model/ProtectedDataEgressMode.cs
@@ -0,0 +1,16 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Per-channel egress mode for protected content in DepartmentProtectedDataEgressPolicies. Every
+ /// channel defaults to GenericOnly ("A protected dispatch is available. Sign in to Resgrid to view
+ /// details."). ProtectedAfterPin (SMS/voice only) releases a minimum approved subset after a
+ /// one-time PIN challenge. AllowProtectedContent requires an explicit, versioned administrator
+ /// warning acknowledgement. Legacy clients and payloads always fall back to GenericOnly.
+ ///
+ public enum ProtectedDataEgressMode
+ {
+ GenericOnly = 0,
+ ProtectedAfterPin = 1,
+ AllowProtectedContent = 2
+ }
+}
diff --git a/Core/Resgrid.Model/ProtectedDataEnvelope.cs b/Core/Resgrid.Model/ProtectedDataEnvelope.cs
new file mode 100644
index 000000000..9f7e19a0f
--- /dev/null
+++ b/Core/Resgrid.Model/ProtectedDataEnvelope.cs
@@ -0,0 +1,94 @@
+using System;
+
+namespace Resgrid.Model
+{
+ ///
+ /// The versioned Advanced Data Protection envelope format:
+ /// text fields carry rgdp:1:{departmentKeyVersion}:{base64(nonce|tag|ciphertext)}; binary
+ /// payloads use the rgdpb variant (same header, raw nonce|tag|ciphertext, no base64).
+ /// This class is FORMAT ONLY — parse, detect and compose. All cryptography stays in the Protected
+ /// Data Broker; nothing in Resgrid.Model encrypts, decrypts or touches key material.
+ ///
+ /// Envelope detection is the authoritative half of the double-encryption guard: the encrypt path
+ /// refuses any value already carrying a parseable envelope, and the decrypt path passes
+ /// non-enveloped values through untouched.
+ ///
+ public static class ProtectedDataEnvelope
+ {
+ /// Prefix of a text envelope, including the trailing separator.
+ public const string Prefix = "rgdp:";
+
+ /// Marker for the binary envelope variant (raw bytes, no base64).
+ public const string BinaryPrefix = "rgdpb:";
+
+ /// Current envelope format version.
+ public const int CurrentVersion = 1;
+
+ ///
+ /// The exact placeholder unattended consumers (Workflow, safe projections, logs) receive in
+ /// place of a protected value. Compare with ordinal equality; never localize.
+ ///
+ public const string RedactionValue = "REDACTED";
+
+ /// True when the value starts with either envelope prefix (cheap pre-check).
+ public static bool HasEnvelopePrefix(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return false;
+
+ return value.StartsWith(Prefix, StringComparison.Ordinal) ||
+ value.StartsWith(BinaryPrefix, StringComparison.Ordinal);
+ }
+
+ ///
+ /// Parses a text envelope. Returns false for null/empty values, plaintext, and malformed or
+ /// unknown-version envelopes — callers must treat an unparseable value that still carries the
+ /// prefix as corrupt (fail closed, preserve bytes) rather than as plaintext.
+ ///
+ public static bool TryParse(string value, out int formatVersion, out int departmentKeyVersion, out string payloadBase64)
+ {
+ formatVersion = 0;
+ departmentKeyVersion = 0;
+ payloadBase64 = null;
+
+ if (string.IsNullOrEmpty(value) || !value.StartsWith(Prefix, StringComparison.Ordinal))
+ return false;
+
+ // rgdp:{version}:{keyVersion}:{payload}
+ var parts = value.Split(':', 4);
+ if (parts.Length != 4)
+ return false;
+
+ // Unknown/future format versions are NOT parseable (the documented contract): a caller
+ // must treat a prefixed value it cannot parse as corrupt, never as a valid envelope.
+ if (!int.TryParse(parts[1], out formatVersion) || formatVersion <= 0 || formatVersion > CurrentVersion)
+ return false;
+
+ if (!int.TryParse(parts[2], out departmentKeyVersion) || departmentKeyVersion <= 0)
+ return false;
+
+ if (string.IsNullOrEmpty(parts[3]))
+ return false;
+
+ payloadBase64 = parts[3];
+ return true;
+ }
+
+ /// True when the value is a well-formed text envelope of a known format version.
+ public static bool IsEnveloped(string value)
+ {
+ return TryParse(value, out _, out _, out _);
+ }
+
+ /// Composes a text envelope from an already-encrypted payload.
+ public static string Format(int departmentKeyVersion, string payloadBase64)
+ {
+ if (departmentKeyVersion <= 0)
+ throw new ArgumentOutOfRangeException(nameof(departmentKeyVersion));
+ if (string.IsNullOrEmpty(payloadBase64))
+ throw new ArgumentException("Envelope payload is required.", nameof(payloadBase64));
+
+ return $"{Prefix}{CurrentVersion}:{departmentKeyVersion}:{payloadBase64}";
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/ProtectedDataGrant.cs b/Core/Resgrid.Model/ProtectedDataGrant.cs
new file mode 100644
index 000000000..b874a4547
--- /dev/null
+++ b/Core/Resgrid.Model/ProtectedDataGrant.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Validated claims of a tenant-bound Protected Data Grant (ADP plan section 3.2). Produced ONLY
+ /// by IProtectedDataGrantService.ValidateGrant after signature, lifetime, audience, department,
+ /// policy-epoch and scope checks pass — never construct one from unvalidated input. Contains no
+ /// key material and no protected values; it is safe to log its identifiers (GrantId, department)
+ /// in value-free audit events.
+ ///
+ public class ProtectedDataGrant
+ {
+ /// Unique grant identifier (jti) — the replay/audit correlation id.
+ public string GrantId { get; set; }
+
+ /// Immutable user id (sub).
+ public string UserId { get; set; }
+
+ /// Exactly one department (dept) — never a list or wildcard.
+ public int DepartmentId { get; set; }
+
+ /// Login session identifier (sid) when the issuing session carried one.
+ public string SessionId { get; set; }
+
+ /// Numeric UserSessionClientApplication the session authenticated as (client_app).
+ public int ClientApp { get; set; }
+
+ /// Department policy epoch at issuance; a later epoch bump revokes this grant.
+ public long PolicyEpoch { get; set; }
+
+ /// Granted protected-operation scopes (see ProtectedDataGrantScopes).
+ public IReadOnlyList Scopes { get; set; }
+
+ /// UTC instant the fresh MFA step-up completed (mfa_at). Absolute; never refreshed.
+ public DateTime MfaAtUtc { get; set; }
+
+ /// UTC issuance instant (iat).
+ public DateTime IssuedAtUtc { get; set; }
+
+ /// Absolute UTC expiry (exp) — the step-up window end; never sliding.
+ public DateTime ExpiresOnUtc { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/ProtectedDataGrantIssueRequest.cs b/Core/Resgrid.Model/ProtectedDataGrantIssueRequest.cs
new file mode 100644
index 000000000..ce929740f
--- /dev/null
+++ b/Core/Resgrid.Model/ProtectedDataGrantIssueRequest.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model
+{
+ ///
+ /// Inputs for issuing a Protected Data Grant. The CALLER (the identity-tier step-up endpoint) is
+ /// responsible for having verified fresh MFA and for supplying the department's CURRENT policy
+ /// epoch and step-up window — the grant service performs no lookups and no MFA checks; it only
+ /// binds already-verified facts into a signed token.
+ ///
+ public class ProtectedDataGrantIssueRequest
+ {
+ public string UserId { get; set; }
+
+ public int DepartmentId { get; set; }
+
+ /// Login session id (sid claim) when the session carries one; null otherwise.
+ public string SessionId { get; set; }
+
+ /// Numeric UserSessionClientApplication of the authenticated session (default Api).
+ public int ClientApp { get; set; }
+
+ /// The department's current policy epoch (0 when the department has no policy row).
+ public long PolicyEpoch { get; set; }
+
+ /// Absolute grant lifetime in minutes (the department step-up window, ceiling-clamped).
+ public int WindowMinutes { get; set; }
+
+ /// Scopes to grant (ProtectedDataGrantScopes values). Empty/null grants nothing.
+ public IReadOnlyList Scopes { get; set; }
+
+ /// UTC instant the MFA step-up completed. The mfa_at claim; never refreshed later.
+ public DateTime MfaAtUtc { get; set; }
+ }
+
+ /// Result of a successful grant issuance. The token is sensitive-in-transit but value-free.
+ public class ProtectedDataGrantIssueResult
+ {
+ /// Unique grant id (jti) for client display, audit and replay correlation.
+ public string GrantId { get; set; }
+
+ /// Compact signed grant token the client presents alongside its ordinary access token.
+ public string Token { get; set; }
+
+ /// Absolute UTC expiry of the grant.
+ public DateTime ExpiresOnUtc { get; set; }
+ }
+
+ ///
+ /// Value-free validation outcomes for a presented Protected Data Grant. Anything but Valid MUST
+ /// fail the protected operation closed; the distinct values exist for audit metrics, never for
+ /// leaking token internals to callers.
+ ///
+ public enum ProtectedDataGrantValidationOutcome
+ {
+ Valid = 0,
+
+ /// No validation key material is configured on this host.
+ NotConfigured = 1,
+
+ /// Missing, unparseable, wrong algorithm, wrong issuer/audience, or bad signature.
+ Invalid = 2,
+
+ /// Signature fine but the grant is outside its absolute lifetime (bounded skew).
+ Expired = 3,
+
+ /// The dept claim does not match the department the operation targets.
+ WrongDepartment = 4,
+
+ /// The department's policy epoch moved past the grant's — the grant is revoked.
+ EpochRevoked = 5,
+
+ /// The grant does not carry the scope the operation requires.
+ MissingScope = 6
+ }
+}
diff --git a/Core/Resgrid.Model/ProtectedDataGrantScopes.cs b/Core/Resgrid.Model/ProtectedDataGrantScopes.cs
new file mode 100644
index 000000000..0a2b86094
--- /dev/null
+++ b/Core/Resgrid.Model/ProtectedDataGrantScopes.cs
@@ -0,0 +1,19 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Scope strings carried in a Protected Data Grant (plan section 3.2 "permissions/scope").
+ /// Version 1 grants the two coarse operation scopes below; per-family and per-permission
+ /// narrowing (Calls vs Personnel vs Contacts, export, egress override, break-glass) joins when
+ /// the protected read-path DTOs ship and enforcement points can request a narrower scope.
+ /// Enforcement of the specific ADP PermissionTypes (31-39) remains a server-side authorization
+ /// check at every operation — a scope in a grant never substitutes for it.
+ ///
+ public static class ProtectedDataGrantScopes
+ {
+ /// Decrypt/read protected field values.
+ public const string Read = "protected:read";
+
+ /// Encrypt/write protected field values.
+ public const string Write = "protected:write";
+ }
+}
diff --git a/Core/Resgrid.Model/ProtectedFieldClassification.cs b/Core/Resgrid.Model/ProtectedFieldClassification.cs
new file mode 100644
index 000000000..2a4671049
--- /dev/null
+++ b/Core/Resgrid.Model/ProtectedFieldClassification.cs
@@ -0,0 +1,18 @@
+namespace Resgrid.Model
+{
+ ///
+ /// Why a catalog field is protected. Classification drives disclosure/egress review, not crypto —
+ /// every cataloged field is encrypted the same way regardless of classification.
+ ///
+ public enum ProtectedFieldClassification
+ {
+ /// May contain protected health information (patient/clinical context).
+ Phi = 1,
+
+ /// May contain personally identifiable information.
+ Pii = 2,
+
+ /// Operationally sensitive user-authored content that may embed PHI/PII free text.
+ Sensitive = 3
+ }
+}
diff --git a/Core/Resgrid.Model/ProtectedFieldDefinition.cs b/Core/Resgrid.Model/ProtectedFieldDefinition.cs
new file mode 100644
index 000000000..0568c14b1
--- /dev/null
+++ b/Core/Resgrid.Model/ProtectedFieldDefinition.cs
@@ -0,0 +1,60 @@
+using System;
+
+namespace Resgrid.Model
+{
+ ///
+ /// One entry of the versioned protected-field catalog. FieldId is STABLE FOREVER — it is bound
+ /// into the AAD of every envelope written for the field, so renaming or renumbering an entry
+ /// after any department has migrated makes that ciphertext unreadable. Add entries; never mutate
+ /// shipped ones.
+ ///
+ public sealed class ProtectedFieldDefinition
+ {
+ public ProtectedFieldDefinition(string fieldId, string family, string tableName, string columnName,
+ ProtectedFieldStorageKind storageKind, ProtectedFieldClassification classification,
+ PermissionTypes viewPermission, PermissionTypes? editPermission = null, int addedInCatalogVersion = 1)
+ {
+ if (string.IsNullOrWhiteSpace(fieldId))
+ throw new ArgumentException("FieldId is required.", nameof(fieldId));
+ if (string.IsNullOrWhiteSpace(tableName))
+ throw new ArgumentException("TableName is required.", nameof(tableName));
+ if (string.IsNullOrWhiteSpace(columnName))
+ throw new ArgumentException("ColumnName is required.", nameof(columnName));
+
+ FieldId = fieldId;
+ Family = family;
+ TableName = tableName;
+ ColumnName = columnName;
+ StorageKind = storageKind;
+ Classification = classification;
+ ViewPermission = viewPermission;
+ EditPermission = editPermission;
+ AddedInCatalogVersion = addedInCatalogVersion;
+ }
+
+ /// Stable catalog field id ("calls.name") — part of the envelope AAD; never changes.
+ public string FieldId { get; }
+
+ /// Catalog family for grant scoping and UI grouping ("Calls", "Contacts", "Personnel").
+ public string Family { get; }
+
+ /// Physical table (SQL Server casing; PostgreSQL is the lowercase form).
+ public string TableName { get; }
+
+ /// Physical column the plaintext lived in.
+ public string ColumnName { get; }
+
+ public ProtectedFieldStorageKind StorageKind { get; }
+
+ public ProtectedFieldClassification Classification { get; }
+
+ /// Permission required (with a current grant) to reveal the field.
+ public PermissionTypes ViewPermission { get; }
+
+ /// Permission required (with a current grant) to write the field; null = ViewPermission governs.
+ public PermissionTypes? EditPermission { get; }
+
+ /// Catalog version that introduced this entry.
+ public int AddedInCatalogVersion { get; }
+ }
+}
diff --git a/Core/Resgrid.Model/ProtectedFieldStorageKind.cs b/Core/Resgrid.Model/ProtectedFieldStorageKind.cs
new file mode 100644
index 000000000..209142336
--- /dev/null
+++ b/Core/Resgrid.Model/ProtectedFieldStorageKind.cs
@@ -0,0 +1,21 @@
+namespace Resgrid.Model
+{
+ ///
+ /// How a cataloged field's ciphertext is persisted (ADP plan sections 22.2–22.3).
+ ///
+ public enum ProtectedFieldStorageKind
+ {
+ /// String column carries the rgdp: text envelope in place (column widened to MAX/citext).
+ Text = 1,
+
+ /// Binary column carries the rgdpb: variant in place (raw nonce|tag|ciphertext, no base64).
+ Binary = 2,
+
+ ///
+ /// Typed (non-string) column such as a decimal coordinate: the typed column is nulled/zeroed
+ /// while protected and a Protected{Name}Envelope companion column carries the value
+ /// (Appendix B pattern).
+ ///
+ CompanionColumn = 3
+ }
+}
diff --git a/Core/Resgrid.Model/Providers/IKeyWrappingProvider.cs b/Core/Resgrid.Model/Providers/IKeyWrappingProvider.cs
new file mode 100644
index 000000000..29ee9e44d
--- /dev/null
+++ b/Core/Resgrid.Model/Providers/IKeyWrappingProvider.cs
@@ -0,0 +1,35 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Providers
+{
+ ///
+ /// KMS adapter seam for department DEK envelope operations (ADP plan sections 2.1 and 4.1).
+ /// Implementations bind every call cryptographically to the department (OpenBao Transit:
+ /// derived=true with context = base64(DepartmentId)); an adapter that cannot provide an
+ /// equivalent binding must be rejected in review. In the target topology only the Protected Data
+ /// Broker's workload identity may reach the KMS — Web/API/worker hosts get no registration that
+ /// can unwrap. No method ever exposes the KEK, and no general unwrap/plaintext-key API exists
+ /// beyond the broker-internal unwrap below.
+ ///
+ public interface IKeyWrappingProvider
+ {
+ /// Provider discriminator persisted on key rows ("OpenBaoTransit", "LocalDev").
+ string ProviderType { get; }
+
+ ///
+ /// Generates a fresh random 256-bit DEK for the department and returns ONLY its wrapped form
+ /// plus provider metadata (OpenBao: transit/datakey/wrapped with the department context).
+ ///
+ Task GenerateWrappedDataKeyAsync(int departmentId, CancellationToken cancellationToken = default);
+
+ ///
+ /// BROKER-INTERNAL ONLY: unwraps a stored DEK for immediate field crypto. The caller must hold
+ /// the plaintext in pinned memory and zero it with CryptographicOperations.ZeroMemory
+ /// immediately after use; it must never be logged, cached in Redis, or returned to any client.
+ /// Fails (throws) rather than falling back when the KMS is unreachable — protection fails
+ /// closed.
+ ///
+ Task UnwrapDataKeyAsync(int departmentId, string wrappedKeyBase64, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs b/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs
new file mode 100644
index 000000000..6d30b3ecc
--- /dev/null
+++ b/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs
@@ -0,0 +1,75 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Providers
+{
+ ///
+ /// Application-tier client for the Protected Data Broker (ADP plan sections 2.1 and 3.1): the
+ /// API authorizes normally, then sends ONLY the required fields plus the caller's grant to the
+ /// broker, which validates the grant and performs the field crypto. This client holds no key
+ /// material and performs no cryptography; it is safe to register on Web/API hosts. Every failure
+ /// is closed: a network fault, timeout, or non-success broker response returns a failed result
+ /// with a value-free error code — never a partial plaintext fallback.
+ ///
+ public interface IProtectedDataBrokerClient
+ {
+ /// True when a broker base URL is configured for this deployment.
+ bool IsConfigured { get; }
+
+ ///
+ /// Shallow broker liveness probe (GET /health) for the wizard preflight. False when the
+ /// broker is unconfigured, unreachable, or unhealthy — never throws.
+ ///
+ Task IsHealthyAsync(CancellationToken cancellationToken = default);
+
+ /// Decrypts envelopes for an attended, granted caller. Items carry envelopes in Value.
+ Task DecryptAsync(int departmentId, string grantToken, string requestId,
+ IReadOnlyList items, CancellationToken cancellationToken = default);
+
+ /// Encrypts plaintext for an attended, granted caller. Items carry plaintext in Value.
+ Task EncryptAsync(int departmentId, string grantToken, string requestId,
+ IReadOnlyList items, CancellationToken cancellationToken = default);
+ }
+
+ /// One field value in a broker operation. RowKey is the stable per-row AAD component.
+ public class ProtectedFieldOperationItem
+ {
+ /// Catalog field id ("table.column", lowercase).
+ public string FieldId { get; set; }
+
+ /// Stable per-row key used in the envelope AAD (typically the primary key value).
+ public string RowKey { get; set; }
+
+ /// Envelope (decrypt) or plaintext (encrypt). Text fields only in v1.
+ public string Value { get; set; }
+
+ /// Catalog version the envelope's AAD was bound with.
+ public int CatalogVersion { get; set; }
+ }
+
+ /// Per-item outcome. Value is plaintext (decrypt) or an envelope (encrypt); null on error.
+ public class ProtectedFieldOperationResult
+ {
+ public string FieldId { get; set; }
+
+ public string RowKey { get; set; }
+
+ public string Value { get; set; }
+
+ /// Value-free error code when this item failed (null on success).
+ public string ErrorCode { get; set; }
+ }
+
+ /// Overall broker call result. Success false means NO item was processed (fail closed).
+ public class ProtectedDataBrokerResult
+ {
+ public bool Success { get; set; }
+
+ /// Value-free request-level error code: broker_unavailable, grant_invalid, grant_expired,
+ /// grant_revoked, protected_access_denied, too_many_items, replayed_request, kms_unavailable.
+ public string ErrorCode { get; set; }
+
+ public List Items { get; set; } = new List();
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionBulkRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionBulkRepository.cs
new file mode 100644
index 000000000..44ba6618e
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionBulkRepository.cs
@@ -0,0 +1,55 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ ///
+ /// Bulk data access for the ADP migration engine. Table and column identifiers come exclusively
+ /// from code-reviewed AdpTableBinding constants (never runtime input); values are always bound as
+ /// parameters. Batch writes and the migration row's cursor advance commit in ONE transaction, so
+ /// a crash re-processes at most one batch — which the engine's double-encryption guard makes a
+ /// no-op (plan section 19.4). No method stages plaintext in temp tables or files.
+ ///
+ public interface IDepartmentDataProtectionBulkRepository
+ {
+ /// Total department-owned rows in the bound table (sizing and progress).
+ Task CountRowsAsync(AdpTableBinding binding, int departmentId,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// The next batch of department-owned rows strictly after the cursor, ordered by primary key.
+ /// Selects the bound columns plus companion/marker columns.
+ ///
+ Task> GetBatchAsync(AdpTableBinding binding, int departmentId,
+ string afterCursor, int batchSize, CancellationToken cancellationToken = default);
+
+ ///
+ /// Applies the row updates and advances the migration row's cursor and counters in one
+ /// transaction. Passing no updates still advances the cursor (a batch of already-protected
+ /// rows moves forward durably).
+ ///
+ Task ApplyBatchAsync(AdpTableBinding binding, IReadOnlyList updates,
+ int departmentDataProtectionMigrationId, string newCursor, long rowsProcessedDelta,
+ long rowsAlreadyProtectedDelta, long rowsAnomalousDelta, CancellationToken cancellationToken);
+
+ ///
+ /// Residue scan for text columns. enveloped=false counts rows still holding plaintext in any
+ /// bound text column (enrollment verification must find zero); enveloped=true counts rows
+ /// still holding an rgdp: envelope (offboarding verification must find zero).
+ ///
+ Task CountTextResidueAsync(AdpTableBinding binding, int departmentId, bool enveloped,
+ CancellationToken cancellationToken = default);
+
+ /// Residue scan for binary columns (rgdpb header prefix compare).
+ Task CountBinaryResidueAsync(AdpTableBinding binding, int departmentId, bool enveloped,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Residue scan for companion-column fields: enrollment residue = typed column still non-null;
+ /// offboarding residue = companion envelope column still non-null.
+ ///
+ Task CountCompanionResidueAsync(AdpTableBinding binding, int departmentId, bool enveloped,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionKeyRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionKeyRepository.cs
new file mode 100644
index 000000000..22b230adb
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionKeyRepository.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IDepartmentDataProtectionKeyRepository : IRepository
+ {
+ /// The single Active key version for the department, or null when none is provisioned.
+ Task GetActiveByDepartmentIdAsync(int departmentId);
+
+ /// Resolves the key row an rgdp envelope references by its department key version.
+ Task GetByDepartmentAndVersionAsync(int departmentId, int version);
+
+ /// All key versions for the department, newest version first.
+ Task> GetAllVersionsByDepartmentIdAsync(int departmentId);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionMigrationRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionMigrationRepository.cs
new file mode 100644
index 000000000..4ed3e4017
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionMigrationRepository.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IDepartmentDataProtectionMigrationRepository : IRepository
+ {
+ /// All cursor rows for the department's in-flight run of the given kind (CompletedOn null).
+ Task> GetActiveByDepartmentIdAsync(int departmentId,
+ DepartmentDataProtectionMigrationKind kind);
+
+ /// The department's cursor row for one table of an in-flight run, or null.
+ Task GetActiveByDepartmentAndTableAsync(int departmentId,
+ DepartmentDataProtectionMigrationKind kind, string targetTable);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionPolicyRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionPolicyRepository.cs
new file mode 100644
index 000000000..eb38de969
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IDepartmentDataProtectionPolicyRepository.cs
@@ -0,0 +1,26 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IDepartmentDataProtectionPolicyRepository : IRepository
+ {
+ Task GetByDepartmentIdAsync(int departmentId);
+
+ ///
+ /// Compare-and-swap state transition: moves the department's policy from
+ /// to only when the row still holds
+ /// the expected state, so two concurrent commands cannot both win a transition. Returns the
+ /// number of rows affected (0 = lost the race / wrong state).
+ ///
+ Task TryTransitionStateAsync(int departmentId, DepartmentDataProtectionState expectedState,
+ DepartmentDataProtectionState newState, int? activeMigrationKind, string updatedByUserId,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Atomically increments the department's policy epoch (revoking outstanding grants) and returns
+ /// the new epoch value; 0 when the department has no policy row.
+ ///
+ Task IncrementPolicyEpochAsync(int departmentId, string updatedByUserId, CancellationToken cancellationToken);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IDepartmentMemberSensitiveDataRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentMemberSensitiveDataRepository.cs
new file mode 100644
index 000000000..eed87cf18
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IDepartmentMemberSensitiveDataRepository.cs
@@ -0,0 +1,9 @@
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IDepartmentMemberSensitiveDataRepository : IRepository
+ {
+ Task GetByDepartmentAndUserAsync(int departmentId, string userId);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IDepartmentOperationLockRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentOperationLockRepository.cs
new file mode 100644
index 000000000..2ebb881dc
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IDepartmentOperationLockRepository.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IDepartmentOperationLockRepository : IRepository
+ {
+ /// The department's active (unreleased) lock, or null.
+ Task GetActiveByDepartmentIdAsync(int departmentId);
+
+ /// Every active lock across all departments (BackOffice Locks view).
+ Task> GetAllActiveAsync();
+
+ ///
+ /// Inserts the lock as the department's single active lock. The one-active-lock-per-department
+ /// invariant is enforced by a filtered/partial unique index, so a concurrent second acquire
+ /// fails at the database rather than racing; returns false in that case without throwing.
+ ///
+ Task TryAcquireAsync(DepartmentOperationLock departmentLock, CancellationToken cancellationToken);
+
+ /// Advances HeartbeatUtc (and optionally ExpiresUtc) on an active lock; 0 rows = lock gone.
+ Task HeartbeatAsync(int departmentOperationLockId, DateTime heartbeatUtc, DateTime? newExpiresUtc,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Releases an active lock with the given kind. Only releases when still active, so a release
+ /// racing an expiry cannot double-write; returns rows affected.
+ ///
+ Task ReleaseAsync(int departmentOperationLockId, DepartmentOperationLockReleaseKind kind,
+ string releasedBy, DateTime releasedUtc, CancellationToken cancellationToken);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IDepartmentProtectedDataEgressPolicyRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentProtectedDataEgressPolicyRepository.cs
new file mode 100644
index 000000000..74db948b5
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IDepartmentProtectedDataEgressPolicyRepository.cs
@@ -0,0 +1,9 @@
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IDepartmentProtectedDataEgressPolicyRepository : IRepository
+ {
+ Task GetByDepartmentIdAsync(int departmentId);
+ }
+}
diff --git a/Core/Resgrid.Model/Security/SessionClaimTypes.cs b/Core/Resgrid.Model/Security/SessionClaimTypes.cs
index 0109e2dff..679a0912d 100644
--- a/Core/Resgrid.Model/Security/SessionClaimTypes.cs
+++ b/Core/Resgrid.Model/Security/SessionClaimTypes.cs
@@ -5,5 +5,13 @@ public static class SessionClaimTypes
public const string SessionId = "sid";
public const string AuthenticationGeneration = "auth_ver";
public const string WebEventingOnly = "web_eventing_only";
+
+ ///
+ /// Numeric UserSessionClientApplication value of the application the session authenticated
+ /// as. Lets the API step down unattended clients structurally (ADP plan section 7.3:
+ /// BigBoard gets safe shells for protected departments). Tokens issued before this claim
+ /// existed simply lack it and read as the default Api client.
+ ///
+ public const string ClientApp = "client_app";
}
}
diff --git a/Core/Resgrid.Model/Services/IAdpSizingService.cs b/Core/Resgrid.Model/Services/IAdpSizingService.cs
new file mode 100644
index 000000000..7ac18d3f5
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IAdpSizingService.cs
@@ -0,0 +1,16 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Read-only ADP sizing scan (plan section 18.2). Counts department-owned rows per cataloged
+ /// table and derives the P50–P90 migration estimate plus the projected number of overnight
+ /// windows. Used by Enrollment Wizard step 5 (persisted with the acknowledgement record) and
+ /// re-run by the migration worker on execution night to detect projection drift.
+ ///
+ public interface IAdpSizingService
+ {
+ Task RunSizingScanAsync(int departmentId, int windowMinutes, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IDepartmentDataMigrationEngine.cs b/Core/Resgrid.Model/Services/IDepartmentDataMigrationEngine.cs
new file mode 100644
index 000000000..36b815201
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IDepartmentDataMigrationEngine.cs
@@ -0,0 +1,34 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// The bulk field-crypto engine behind the ADP migration worker (plan sections 19.3–19.4): walks
+ /// the protected-field catalog table by table in bounded, transactional batches with durable
+ /// cursors, enforcing the double-encryption guard (encrypt refuses matching rgdp envelopes and
+ /// hard-errors on foreign ones; decrypt passes plaintext through and counts the anomaly). All
+ /// crypto goes through the Protected Data Broker with a migration-scoped workload audience —
+ /// never a user grant, never plaintext staging tables/files. The coordinator
+ /// (AdpMigrationLogic) owns scheduling, the department lock, state transitions and
+ /// notifications; the engine owns only data movement and verification scans.
+ ///
+ public interface IDepartmentDataMigrationEngine
+ {
+ /// True when a real engine (broker-backed) is available on this host.
+ bool IsAvailable { get; }
+
+ /// Runs one enrollment (encryption) night for the department, up to the window close.
+ Task RunEncryptionNightAsync(AdpMigrationNightContext context, CancellationToken cancellationToken);
+
+ /// Runs one offboarding (decryption) night for the department, up to the window close.
+ Task RunDecryptionNightAsync(AdpMigrationNightContext context, CancellationToken cancellationToken);
+
+ ///
+ /// Post-run verification: counts, AEAD/AAD spot checks, catalog coverage, and the
+ /// plaintext-residue scan (enrollment) or envelope-residue scan (offboarding). Only a true
+ /// result may transition the department out of Verifying.
+ ///
+ Task VerifyAsync(AdpMigrationNightContext context, CancellationToken cancellationToken);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs b/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs
new file mode 100644
index 000000000..3118074fc
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Advanced Data Protection policy/state orchestration for departments. Owns the durable
+ /// DepartmentDataProtectionPolicies state machine (the single data-safety truth), the per-channel
+ /// egress policy, and the server-enforced enrollment/offboarding command gates: managing member
+ /// only, active paid ADP addon, and a fresh authoritative global-gate evaluation — never trusting
+ /// client/UI state. Bulk state transitions beyond queueing are made only by the ADP migration
+ /// worker. This service performs no cryptography.
+ ///
+ public interface IDepartmentDataProtectionService
+ {
+ /// The department's policy row, or null when the department has never touched ADP.
+ Task GetPolicyByDepartmentIdAsync(int departmentId, bool bypassCache = false);
+
+ /// Durable protection state; Disabled when no policy row exists.
+ Task GetStateAsync(int departmentId, bool bypassCache = false);
+
+ ///
+ /// True when writers must envelope-encrypt newly changed cataloged fields: the department has a
+ /// provisioned key and is Encrypting/Verifying(enrollment or rotation)/Enabled/Rotating/
+ /// OffboardingScheduled. False for Disabled, queued/provisioning, and the offboarding decrypt
+ /// path (DisableRequested/Decrypting/offboarding-Verifying), where new writes stay plaintext so
+ /// the decrypt backlog only shrinks.
+ ///
+ Task ShouldEncryptNewWritesAsync(int departmentId);
+
+ ///
+ /// True when protected-data enforcement (grants, shields, redacted projections) applies to
+ /// reads: state is Enabled, Rotating or OffboardingScheduled — protection stays fully active
+ /// while offboarding is merely scheduled.
+ ///
+ Task IsProtectionEnforcedAsync(int departmentId);
+
+ ///
+ /// Queues enrollment (Disabled -> EnrollmentQueued) after enforcing, server-side: caller is
+ /// Department.ManagingUserId; department is on a paid plan with an active paid ADP addon; a
+ /// fresh authoritative (bypass-cache) evaluation of the global admission gate is true; and the
+ /// durable state is Disabled. Persists the acknowledgement record, selected overnight window,
+ /// and the value-free flag-evaluation audit reference on the policy row.
+ ///
+ Task QueueEnrollmentAsync(int departmentId, string requestingUserId,
+ string acknowledgementsJson, string windowStartLocal, string windowEndLocal, string windowTimeZone,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Dequeues a not-yet-started enrollment (EnrollmentQueued -> Disabled) at no data cost.
+ /// Managing member only.
+ ///
+ Task CancelQueuedEnrollmentAsync(int departmentId,
+ string requestingUserId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Schedules offboarding (Enabled -> OffboardingScheduled) at the end of the paid cycle.
+ /// Protection, grants and egress remain fully active until the offboarding migration runs.
+ /// Called from the billing event path (cancellation, exhausted dunning, chargeback).
+ ///
+ Task ScheduleOffboardingAsync(int departmentId,
+ DepartmentDataProtectionOffboardingSource source, DateTime effectiveOnUtc,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Revokes a scheduled offboarding (OffboardingScheduled -> Enabled) before the first
+ /// offboarding window opens. Managing member only; not offered once decryption has begun.
+ ///
+ Task RevokeOffboardingAsync(int departmentId,
+ string requestingUserId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Per-channel egress policy; when the department has no row, returns an unsaved default with
+ /// every channel GenericOnly (the fail-safe posture).
+ ///
+ Task GetEgressPolicyByDepartmentIdAsync(int departmentId, bool bypassCache = false);
+
+ ///
+ /// Saves the egress policy and increments the department policy epoch, revoking outstanding
+ /// grants and forcing send-time re-evaluation of pending protected deliveries.
+ ///
+ Task SaveEgressPolicyAsync(DepartmentProtectedDataEgressPolicy policy,
+ string updatedByUserId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Advisory readiness report for the Enrollment Wizard preflight (plan section 18.1 step 4):
+ /// managing member, paid plan, active addon, fresh global-gate evaluation, and Disabled
+ /// state. Every check is re-verified inside QueueEnrollmentAsync at commit — this never
+ /// substitutes for the command gates.
+ ///
+ Task GetEnrollmentPreflightAsync(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);
+
+ /// Drops the department's cached policy/egress state (called after every mutation).
+ Task InvalidateProtectionCacheAsync(int departmentId);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IDepartmentKeyService.cs b/Core/Resgrid.Model/Services/IDepartmentKeyService.cs
new file mode 100644
index 000000000..f01852a2d
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IDepartmentKeyService.cs
@@ -0,0 +1,36 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Department DEK version lifecycle (ADP plan sections 4.3 and 11.3): provision, activate,
+ /// resolve by envelope version, retire after rotation. Key metadata only — this service never
+ /// sees plaintext key material; wrapped blobs come from IKeyWrappingProvider and unwrapping
+ /// happens exclusively inside the Protected Data Broker. Key rows are never deleted here;
+ /// cryptographic erasure is a separate dual-controlled retention operation.
+ ///
+ public interface IDepartmentKeyService
+ {
+ /// The department's single Active key version, or null when none is provisioned.
+ Task GetActiveKeyAsync(int departmentId);
+
+ /// Resolves the key row an rgdp envelope references; null when unknown (fail closed upstream).
+ Task GetKeyByVersionAsync(int departmentId, int version);
+
+ ///
+ /// Provisions the next key version for the department (version 1 at enrollment, N+1 at
+ /// rotation): generates a wrapped DEK via the provider, persists it Pending, then activates it
+ /// and moves any previously Active version to Retiring. Returns the new Active row. Idempotent
+ /// guard: refuses (returns the existing row) when a Pending/Active row already exists at the
+ /// computed version.
+ ///
+ Task ProvisionNextKeyVersionAsync(int departmentId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Marks a Retiring version Retired once rotation re-encryption has verified no envelope still
+ /// references it. Never deletes the row.
+ ///
+ Task RetireKeyVersionAsync(int departmentId, int version, CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IDepartmentLockService.cs b/Core/Resgrid.Model/Services/IDepartmentLockService.cs
new file mode 100644
index 000000000..79f84ef93
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IDepartmentLockService.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Department operation lock control plane (ADP plan section 20). While a department lock is
+ /// active, department-scoped mutations are refused (423 department_locked) and reads continue.
+ /// Enforcement callers use on the hot path (short-TTL cache
+ /// with immediate invalidation on change); the ADP migration worker owns apply/heartbeat/release.
+ /// A lock whose heartbeat has gone stale past ExpiresUtc no longer enforces — dispatch
+ /// availability beats migration progress.
+ ///
+ public interface IDepartmentLockService
+ {
+ ///
+ /// True when the department has an active, unexpired lock. Served from a short-TTL cache; an
+ /// expired lock reports false immediately even before the sweep releases it durably.
+ ///
+ Task IsDepartmentLockedAsync(int departmentId);
+
+ /// The department's active lock row, or null. Expired locks are still returned (callers see ExpiresUtc).
+ Task GetActiveLockAsync(int departmentId, bool bypassCache = false);
+
+ /// Every active lock across departments (BackOffice Locks view).
+ Task> GetAllActiveLocksAsync();
+
+ ///
+ /// Acquires the department's single active lock. Returns the created lock, or null when
+ /// another active lock already exists (the invariant is enforced by the database).
+ ///
+ Task ApplyLockAsync(int departmentId, DepartmentOperationLockType lockType,
+ string reason, string correlationId, string appliedByIdentity, DateTime expiresUtc,
+ DateTime? projectedEndUtc, CancellationToken cancellationToken = default);
+
+ /// Advances the worker heartbeat; optionally extends the safety valve. False when the lock is gone.
+ Task HeartbeatAsync(int departmentOperationLockId, DateTime? newExpiresUtc = null,
+ CancellationToken cancellationToken = default);
+
+ /// Releases an active lock (Completed/Checkpoint/Aborted). False when it was already released.
+ Task ReleaseLockAsync(int departmentOperationLockId, DepartmentOperationLockReleaseKind kind,
+ string releasedBy, CancellationToken cancellationToken = default);
+
+ ///
+ /// Liveness sweep: durably releases (as Expired) every active lock whose ExpiresUtc has passed
+ /// with a stale heartbeat. Returns the released locks so the caller can mark their migrations
+ /// Failed and page operators.
+ ///
+ Task> ReleaseExpiredLocksAsync(CancellationToken cancellationToken = default);
+
+ /// Drops the department's cached lock state immediately (called on every apply/release).
+ Task InvalidateLockCacheAsync(int departmentId);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IProtectedDataGrantService.cs b/Core/Resgrid.Model/Services/IProtectedDataGrantService.cs
new file mode 100644
index 000000000..abb35da62
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IProtectedDataGrantService.cs
@@ -0,0 +1,38 @@
+using System;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Issues and validates tenant-bound Protected Data Grants (ADP plan section 3): short-lived
+ /// signed tokens binding user, single department, session, client application, policy epoch,
+ /// scopes and fresh-MFA time. Issuance runs ONLY on the identity tier (the step-up endpoint,
+ /// after fresh TOTP); validation runs on the Protected Data Broker and at API enforcement
+ /// points. No DEK or protected value ever appears in a grant. The service is pure token
+ /// cryptography — MFA verification, permission resolution and policy-epoch lookups belong to
+ /// its callers, and validation is fail closed: any outcome except Valid denies the operation.
+ ///
+ public interface IProtectedDataGrantService
+ {
+ /// True when signing key material (private key) is configured on this host.
+ bool CanIssueGrants { get; }
+
+ /// True when validation key material (public key) is configured on this host.
+ bool CanValidateGrants { get; }
+
+ ///
+ /// Signs a grant from already-verified facts. Throws InvalidOperationException when signing
+ /// is not configured (check CanIssueGrants first) and ArgumentException on an unusable
+ /// request. Lifetime is absolute and clamped to the operator ceiling.
+ ///
+ ProtectedDataGrantIssueResult IssueGrant(ProtectedDataGrantIssueRequest request);
+
+ ///
+ /// Validates a presented grant token: pinned algorithm, signature, issuer, audience,
+ /// absolute lifetime with small bounded skew, exact department match, current policy epoch,
+ /// and the required scope. Returns Valid and the parsed claims, or a value-free failure
+ /// outcome with a null grant. Never throws on malformed input.
+ ///
+ ProtectedDataGrantValidationOutcome ValidateGrant(string token, int expectedDepartmentId,
+ long currentPolicyEpoch, string requiredScope, out ProtectedDataGrant grant, DateTime? utcNow = null);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IProtectedFieldCatalog.cs b/Core/Resgrid.Model/Services/IProtectedFieldCatalog.cs
new file mode 100644
index 000000000..73806ef56
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IProtectedFieldCatalog.cs
@@ -0,0 +1,28 @@
+using System.Collections.Generic;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// The versioned, code-reviewed protected-field catalog (ADP plan section 5). Static data — the
+ /// catalog changes only by code review and a version increment, never at runtime. Departments
+ /// record the catalog version they migrated to; the shield UI shows only after that version's
+ /// migration is verified.
+ ///
+ public interface IProtectedFieldCatalog
+ {
+ /// Current catalog version. Incremented whenever entries are added.
+ int Version { get; }
+
+ /// Every catalog entry.
+ IReadOnlyList GetAll();
+
+ /// Entries for one physical table (SQL Server casing; lookup is case-insensitive). Empty when none.
+ IReadOnlyList GetForTable(string tableName);
+
+ /// The entry with the given stable field id, or null.
+ ProtectedFieldDefinition GetById(string fieldId);
+
+ /// True when (table, column) is cataloged (case-insensitive).
+ bool IsProtectedField(string tableName, string columnName);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IProtectedFieldCryptoService.cs b/Core/Resgrid.Model/Services/IProtectedFieldCryptoService.cs
new file mode 100644
index 000000000..faefdea65
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IProtectedFieldCryptoService.cs
@@ -0,0 +1,46 @@
+namespace Resgrid.Model.Services
+{
+ ///
+ /// AEAD field cryptography for ADP envelopes (plan section 4.1). Pure and stateless: the caller
+ /// supplies the unwrapped DEK (pinned memory, zeroed by its owner after use) and the AAD binding
+ /// components; this service never touches key management, storage, or the KMS. AAD binds
+ /// DepartmentId, the stable catalog field id, the stable per-row key, and the envelope/catalog
+ /// versions — moving ciphertext between tenants, rows, fields, or catalog versions fails
+ /// authentication rather than decrypting.
+ ///
+ public interface IProtectedFieldCryptoService
+ {
+ ///
+ /// Encrypts a text field into an rgdp: envelope. Throws if the value already carries an
+ /// envelope prefix — the double-encryption guard belongs to the caller, and reaching this
+ /// method with enveloped input is a caller bug, never something to encrypt again.
+ ///
+ string EncryptText(byte[] dek, int departmentKeyVersion, string plaintext,
+ int departmentId, string catalogFieldId, string rowKey, int catalogVersion);
+
+ ///
+ /// Decrypts an rgdp: envelope back to text. Throws on AAD mismatch (foreign ciphertext), a
+ /// malformed envelope, or an unsupported format version — never returns garbage.
+ ///
+ string DecryptText(byte[] dek, string envelope,
+ int departmentId, string catalogFieldId, string rowKey, int catalogVersion);
+
+ /// Encrypts a binary field into the rgdpb variant (raw header + nonce|tag|ciphertext, no base64).
+ byte[] EncryptBinary(byte[] dek, int departmentKeyVersion, byte[] plaintext,
+ int departmentId, string catalogFieldId, string rowKey, int catalogVersion);
+
+ /// Decrypts an rgdpb blob back to bytes; throws on AAD mismatch or malformed input.
+ byte[] DecryptBinary(byte[] dek, byte[] envelope,
+ int departmentId, string catalogFieldId, string rowKey, int catalogVersion);
+
+ /// True when the blob starts with the rgdpb binary envelope header.
+ bool IsBinaryEnveloped(byte[] value);
+
+ ///
+ /// Reads the department key version from an rgdpb header without decrypting (the decrypt path
+ /// resolves the DEK per envelope version). False for anything that is not a well-formed
+ /// supported-version binary envelope.
+ ///
+ bool TryGetBinaryEnvelopeKeyVersion(byte[] value, out int departmentKeyVersion);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IProtectedProjectionService.cs b/Core/Resgrid.Model/Services/IProtectedProjectionService.cs
new file mode 100644
index 000000000..a8295a6db
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IProtectedProjectionService.cs
@@ -0,0 +1,42 @@
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ ///
+ /// Builds safe projections of department data for unattended consumers (ADP plan section 8).
+ /// Workflows never receive protected plaintext: when a department's protection is enforced,
+ /// cataloged scalars become the exact REDACTED placeholder, cataloged binaries are omitted, and
+ /// the projection carries is_redacted / redacted_fields / catalog_version metadata — all BEFORE
+ /// serialization reaches any queue, run record, retry, dead letter, or designer preview. Never
+ /// serialize plaintext and regex-redact afterward.
+ ///
+ public interface IProtectedProjectionService
+ {
+ ///
+ /// Serializes a workflow event payload for the department. Unprotected departments get the
+ /// plain serialization; enforced departments get the redacted projection. A redaction fault
+ /// never falls back to plaintext — it degrades to a minimal safe payload.
+ ///
+ Task BuildSafeWorkflowPayloadAsync(int departmentId, object eventPayload);
+
+ ///
+ /// The notification-safe view of a call for one egress channel (plan sections 9.1/9.3).
+ /// Returns the ORIGINAL call when the department is not protection-enforced, or when its
+ /// egress policy explicitly allows protected content on that channel. Otherwise returns a
+ /// sanitized clone: the system-generated call number and structural/routing fields survive;
+ /// every cataloged user-authored field is nulled and the nature reads the generic
+ /// "sign in to Resgrid" line — safe to hand to any template, provider DTO, or TTS builder.
+ /// ProtectedAfterPin behaves as GenericOnly until the PIN-release flow ships.
+ ///
+ Task BuildNotificationSafeCallAsync(int departmentId, Call call, ProtectedDataEgressChannel channel);
+
+ ///
+ /// True when this channel must receive only sanitized (generic) content for the department:
+ /// protection is enforced and the channel's egress mode does not allow protected content.
+ /// Exists for notifications that carry protected data WITHOUT a call (trouble alerts, unit
+ /// locations, personnel rosters) — the channel decision must never depend on a call object
+ /// being present. Fails closed: an unknown protection state reads as sanitized.
+ ///
+ Task IsChannelSanitizedAsync(int departmentId, ProtectedDataEgressChannel channel);
+ }
+}
diff --git a/Core/Resgrid.Model/UnitState.cs b/Core/Resgrid.Model/UnitState.cs
index c83b9ff59..d60766057 100644
--- a/Core/Resgrid.Model/UnitState.cs
+++ b/Core/Resgrid.Model/UnitState.cs
@@ -63,6 +63,30 @@ public class UnitState : IEntity
[DecimalPrecision(5, 2)]
public decimal? Heading { get; set; }
+ /// ADP: true when this row's cataloged values carry rgdp envelopes (M0129; inert until catalog v2).
+ public bool IsProtected { get; set; }
+
+ /// ADP companion column: envelope for Latitude while protected; typed column is nulled (plan 22.3).
+ public string ProtectedLatitudeEnvelope { get; set; }
+
+ /// ADP companion column: envelope for Longitude while protected; typed column is nulled.
+ public string ProtectedLongitudeEnvelope { get; set; }
+
+ /// ADP companion column: envelope for Accuracy while protected; typed column is nulled.
+ public string ProtectedAccuracyEnvelope { get; set; }
+
+ /// ADP companion column: envelope for Altitude while protected; typed column is nulled.
+ public string ProtectedAltitudeEnvelope { get; set; }
+
+ /// ADP companion column: envelope for AltitudeAccuracy while protected; typed column is nulled.
+ public string ProtectedAltitudeAccuracyEnvelope { get; set; }
+
+ /// ADP companion column: envelope for Speed while protected; typed column is nulled.
+ public string ProtectedSpeedEnvelope { get; set; }
+
+ /// ADP companion column: envelope for Heading while protected; typed column is nulled.
+ public string ProtectedHeadingEnvelope { get; set; }
+
[ForeignKey("UnitId")]
public virtual Unit Unit { get; set; }
diff --git a/Core/Resgrid.Model/WrappedDataKey.cs b/Core/Resgrid.Model/WrappedDataKey.cs
new file mode 100644
index 000000000..3b61f3cc2
--- /dev/null
+++ b/Core/Resgrid.Model/WrappedDataKey.cs
@@ -0,0 +1,23 @@
+namespace Resgrid.Model
+{
+ ///
+ /// A freshly generated department data encryption key in its KMS-wrapped form, as returned by an
+ /// IKeyWrappingProvider. Contains no plaintext key material — the plaintext half of a datakey
+ /// operation exists only inside the Protected Data Broker's hardened memory and is never part of
+ /// this type.
+ ///
+ public sealed class WrappedDataKey
+ {
+ /// Base64 wrapped DEK blob, stored verbatim in DepartmentDataProtectionKeys.WrappedKey.
+ public string WrappedKeyBase64 { get; set; }
+
+ /// Provider discriminator ("OpenBaoTransit", "LocalDev", ...).
+ public string ProviderType { get; set; }
+
+ /// Provider key reference (for OpenBao Transit: mount and key name).
+ public string ProviderKeyReference { get; set; }
+
+ /// KEK version at the provider that wrapped this DEK (for rewrap tracking).
+ public int ProviderKeyVersion { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Services/AdpSizingService.cs b/Core/Resgrid.Services/AdpSizingService.cs
new file mode 100644
index 000000000..e30e34f8b
--- /dev/null
+++ b/Core/Resgrid.Services/AdpSizingService.cs
@@ -0,0 +1,58 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Read-only ADP sizing scan. See for the contract.
+ /// Estimate = rows / benchmark_throughput + fixed per-table overhead, plus the verification
+ /// allowance; P90 = P50 × the configured multiplier (plan section 18.2). Counts only — no
+ /// content is read.
+ ///
+ public class AdpSizingService : IAdpSizingService
+ {
+ private readonly IDepartmentDataProtectionBulkRepository _bulkRepository;
+
+ public AdpSizingService(IDepartmentDataProtectionBulkRepository bulkRepository)
+ {
+ _bulkRepository = bulkRepository;
+ }
+
+ public async Task RunSizingScanAsync(int departmentId, int windowMinutes,
+ CancellationToken cancellationToken = default)
+ {
+ var result = new AdpSizingResult
+ {
+ DepartmentId = departmentId,
+ ScannedOnUtc = DateTime.UtcNow,
+ BenchmarkRowsPerSecond = Math.Max(1, Config.DataProtectionConfig.MigrationBenchmarkRowsPerSecond)
+ };
+
+ foreach (var binding in AdpTableBindings.V1)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var rows = await _bulkRepository.CountRowsAsync(binding, departmentId, cancellationToken);
+ result.TableRowCounts[binding.TableName] = rows;
+ result.TotalRows += rows;
+ }
+
+ var migrationSeconds = (double)result.TotalRows / result.BenchmarkRowsPerSecond
+ + (double)AdpTableBindings.V1.Count * Math.Max(0, Config.DataProtectionConfig.MigrationEstimatePerTableOverheadSeconds);
+ var p50Seconds = migrationSeconds * (1 + Math.Max(0, Config.DataProtectionConfig.MigrationEstimateVerificationAllowance));
+ var p90Seconds = p50Seconds * Math.Max(1, Config.DataProtectionConfig.MigrationEstimateP90Multiplier);
+
+ result.EstimatedP50Minutes = (int)Math.Ceiling(p50Seconds / 60);
+ result.EstimatedP90Minutes = (int)Math.Ceiling(p90Seconds / 60);
+
+ var window = Math.Max(60, windowMinutes);
+ result.ProjectedNights = Math.Max(1, (int)Math.Ceiling((double)result.EstimatedP90Minutes / window));
+
+ return result;
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs
new file mode 100644
index 000000000..125f9a6fe
--- /dev/null
+++ b/Core/Resgrid.Services/AdpTableBindings.cs
@@ -0,0 +1,90 @@
+using System.Collections.Generic;
+using Resgrid.Model;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Code-reviewed table bindings for catalog v1 (P0 families), shared by the migration engine and
+ /// the sizing scan. FieldIds MUST match ProtectedFieldCatalog exactly — they are AAD components
+ /// and stable forever. Child tables derive department ownership through their verified parent
+ /// (plan section 6). Add bindings only together with their catalog entries and, where a typed
+ /// column is involved, the companion-column migration.
+ ///
+ public static class AdpTableBindings
+ {
+ public static readonly IReadOnlyList V1 = Build();
+
+ private static IReadOnlyList Build()
+ {
+ AdpColumnSpec Text(string table, string column) =>
+ new AdpColumnSpec(column, $"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", ProtectedFieldStorageKind.Text);
+ AdpColumnSpec Binary(string table, string column) =>
+ new AdpColumnSpec(column, $"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", ProtectedFieldStorageKind.Binary);
+ AdpColumnSpec Companion(string table, string column) =>
+ new AdpColumnSpec(column, $"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}",
+ ProtectedFieldStorageKind.CompanionColumn, $"Protected{column}Envelope");
+
+ return new List
+ {
+ AdpTableBinding.Direct("Calls", "CallId", pkIsNumeric: true, "DepartmentId", new[]
+ {
+ Text("Calls", "Name"), Text("Calls", "Type"), Text("Calls", "NatureOfCall"),
+ Text("Calls", "Notes"), Text("Calls", "CompletedNotes"), Text("Calls", "Address"),
+ Text("Calls", "GeoLocationData"), Text("Calls", "W3W"), Text("Calls", "ContactName"),
+ Text("Calls", "ContactNumber"), Text("Calls", "SourceIdentifier"), Text("Calls", "IncidentNumber"),
+ Text("Calls", "ExternalIdentifier"), Text("Calls", "ReferenceNumber"), Text("Calls", "CallFormData"),
+ Text("Calls", "DeletedReason")
+ }),
+
+ AdpTableBinding.ViaParent("CallNotes", "CallNoteId", pkIsNumeric: true, "CallId", "Calls", "CallId", new[]
+ {
+ Text("CallNotes", "Note"), Text("CallNotes", "FlaggedReason"),
+ Companion("CallNotes", "Latitude"), Companion("CallNotes", "Longitude")
+ }) with { ProtectedMarkerColumn = "IsProtected" },
+
+ AdpTableBinding.ViaParent("CallAttachments", "CallAttachmentId", pkIsNumeric: true, "CallId", "Calls", "CallId", new[]
+ {
+ Text("CallAttachments", "Name"), Text("CallAttachments", "FileName"),
+ Text("CallAttachments", "FlaggedReason"), Binary("CallAttachments", "Data"),
+ Companion("CallAttachments", "Latitude"), Companion("CallAttachments", "Longitude")
+ }) with { ProtectedMarkerColumn = "IsProtected" },
+
+ AdpTableBinding.Direct("CallLogs", "CallLogId", pkIsNumeric: true, "DepartmentId", new[]
+ {
+ Text("CallLogs", "Narrative")
+ }),
+
+ AdpTableBinding.ViaParent("CallReferences", "CallReferenceId", pkIsNumeric: false, "SourceCallId", "Calls", "CallId", new[]
+ {
+ Text("CallReferences", "Note")
+ }),
+
+ AdpTableBinding.Direct("Contacts", "ContactId", pkIsNumeric: false, "DepartmentId", new[]
+ {
+ Text("Contacts", "FirstName"), Text("Contacts", "MiddleName"), Text("Contacts", "LastName"),
+ Text("Contacts", "OtherName"), Text("Contacts", "CompanyName"), Text("Contacts", "Email"),
+ Text("Contacts", "CountryIssuedIdNumber"), Text("Contacts", "CountryIdName"),
+ Text("Contacts", "StateIdNumber"), Text("Contacts", "StateIdName"), Text("Contacts", "StateIdCountryName"),
+ Text("Contacts", "HomePhoneNumber"), Text("Contacts", "CellPhoneNumber"), Text("Contacts", "FaxPhoneNumber"),
+ Text("Contacts", "OfficePhoneNumber"), Text("Contacts", "Description"), Text("Contacts", "OtherInfo"),
+ Binary("Contacts", "Image"), Text("Contacts", "LocationGpsCoordinates"),
+ Text("Contacts", "EntranceGpsCoordinates"), Text("Contacts", "ExitGpsCoordinates"),
+ Text("Contacts", "LocationGeofence")
+ }),
+
+ AdpTableBinding.ViaParent("ContactNotes", "ContactNoteId", pkIsNumeric: false, "ContactId", "Contacts", "ContactId", new[]
+ {
+ Text("ContactNotes", "Note")
+ }),
+
+ AdpTableBinding.Direct("DepartmentMemberSensitiveData", "DepartmentMemberSensitiveDataId", pkIsNumeric: true, "DepartmentId", new[]
+ {
+ Text("DepartmentMemberSensitiveData", "IdentificationNumber"),
+ Text("DepartmentMemberSensitiveData", "EmergencyContactName"),
+ Text("DepartmentMemberSensitiveData", "EmergencyContactPhone"),
+ Text("DepartmentMemberSensitiveData", "Notes")
+ }) with { ProtectedMarkerColumn = "IsProtected" }
+ };
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs
index b1cbcd500..027f495d9 100644
--- a/Core/Resgrid.Services/CommunicationService.cs
+++ b/Core/Resgrid.Services/CommunicationService.cs
@@ -25,12 +25,14 @@ public class CommunicationService : ICommunicationService
private readonly IUserStateService _userStateService;
private readonly IChatbotOutboundService _chatbotOutboundService;
private readonly IDepartmentsService _departmentsService;
+ private readonly IProtectedProjectionService _protectedProjectionService;
public CommunicationService(ISmsService smsService, IEmailService emailService, IPushService pushService, IGeoLocationProvider geoLocationProvider,
IOutboundVoiceProvider outboundVoiceProvider, IUserProfileService userProfileService, IDepartmentSettingsService departmentSettingsService,
ISubscriptionsService subscriptionsService, IUserStateService userStateService, IChatbotOutboundService chatbotOutboundService,
- IDepartmentsService departmentsService)
+ IDepartmentsService departmentsService, IProtectedProjectionService protectedProjectionService)
{
+ _protectedProjectionService = protectedProjectionService;
_smsService = smsService;
_emailService = emailService;
_pushService = pushService;
@@ -164,6 +166,20 @@ public async Task SendCallAsync(Call call, CallDispatch dispatch, string d
if (profile == null)
profile = await _userProfileService.GetProfileByUserIdAsync(dispatch.UserId);
+ // ADP egress (plan section 9): per-channel notification-safe views, resolved BEFORE any
+ // template, provider DTO, or TTS prompt is built. For unprotected departments every one
+ // of these is the original call; for protected departments each channel gets the
+ // sanitized clone unless its egress mode explicitly allows protected content.
+ var chatCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.ChatPlatform);
+ var pushCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Push);
+ var smsCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Sms);
+ var emailCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Email);
+ var voiceCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Voice);
+
+ // The pre-resolved address parameter is protected location data; it only survives for a
+ // channel whose safe view is the original call.
+ var pushAddress = ReferenceEquals(pushCall, call) ? address : null;
+
// Outbound chat platforms as a sibling channel for call dispatches; failures are isolated.
try
{
@@ -171,9 +187,9 @@ await _chatbotOutboundService.SendToUserAsync(dispatch.UserId, departmentId,
new ChatbotOutboundMessage
{
Type = ChatbotOutboundType.Dispatch,
- Title = string.Format("Call {0}", call.Name),
- Body = string.IsNullOrWhiteSpace(call.Address) ? call.NatureOfCall : call.Address,
- ReferenceId = call.CallId.ToString()
+ Title = string.Format("Call {0}", chatCall.Name),
+ Body = string.IsNullOrWhiteSpace(chatCall.Address) ? chatCall.NatureOfCall : chatCall.Address,
+ ReferenceId = chatCall.CallId.ToString()
});
}
catch (Exception ex)
@@ -187,16 +203,16 @@ await _chatbotOutboundService.SendToUserAsync(dispatch.UserId, departmentId,
try
{
var spc = new StandardPushCall();
- spc.CallId = call.CallId;
- spc.Title = string.Format("Call {0}", call.Name);
- spc.Priority = call.Priority;
+ spc.CallId = pushCall.CallId;
+ spc.Title = string.Format("Call {0}", pushCall.Name);
+ spc.Priority = pushCall.Priority;
spc.ActiveCallCount = 1;
spc.DepartmentId = departmentId;
- spc.DepartmentCode = call.Department?.Code;
+ spc.DepartmentCode = pushCall.Department?.Code;
- if (call.CallPriority != null && !String.IsNullOrWhiteSpace(call.CallPriority.Color))
+ if (pushCall.CallPriority != null && !String.IsNullOrWhiteSpace(pushCall.CallPriority.Color))
{
- spc.Color = call.CallPriority.Color;
+ spc.Color = pushCall.CallPriority.Color;
}
else
{
@@ -205,19 +221,19 @@ await _chatbotOutboundService.SendToUserAsync(dispatch.UserId, departmentId,
string subTitle = String.Empty;
- if (String.IsNullOrWhiteSpace(address) && !String.IsNullOrWhiteSpace(call.Address))
+ if (String.IsNullOrWhiteSpace(pushAddress) && !String.IsNullOrWhiteSpace(pushCall.Address))
{
- subTitle = call.Address;
+ subTitle = pushCall.Address;
}
- else if (!String.IsNullOrWhiteSpace(address))
+ else if (!String.IsNullOrWhiteSpace(pushAddress))
{
- subTitle = address;
+ subTitle = pushAddress;
}
- else if (!string.IsNullOrEmpty(call.GeoLocationData) && call.GeoLocationData.Length > 1)
+ else if (!string.IsNullOrEmpty(pushCall.GeoLocationData) && pushCall.GeoLocationData.Length > 1)
{
try
{
- string[] points = call.GeoLocationData.Split(char.Parse(","));
+ string[] points = pushCall.GeoLocationData.Split(char.Parse(","));
if (points != null && points.Length == 2)
{
@@ -234,8 +250,8 @@ await _chatbotOutboundService.SendToUserAsync(dispatch.UserId, departmentId,
}
else
{
- if (!string.IsNullOrEmpty(call.NatureOfCall))
- spc.SubTitle = call.NatureOfCall.Truncate(200);
+ if (!string.IsNullOrEmpty(pushCall.NatureOfCall))
+ spc.SubTitle = pushCall.NatureOfCall.Truncate(200);
}
if (String.IsNullOrWhiteSpace(spc.SubTitle))
@@ -251,7 +267,7 @@ await _chatbotOutboundService.SendToUserAsync(dispatch.UserId, departmentId,
spc.Title = spc.Title.Replace(char.Parse("/"), char.Parse(" "));
spc.SubTitle = spc.SubTitle.Replace(char.Parse("/"), char.Parse(" "));
- await _pushService.PushCall(spc, dispatch.UserId, profile, call.CallPriority);
+ await _pushService.PushCall(spc, dispatch.UserId, profile, pushCall.CallPriority);
}
catch (Exception ex)
{
@@ -265,7 +281,10 @@ await _chatbotOutboundService.SendToUserAsync(dispatch.UserId, departmentId,
if (profile == null || profile.MobileNumberVerified.IsContactMethodAllowedForSending())
{
var payment = await _subscriptionsService.GetCurrentPaymentForDepartmentAsync(departmentId);
- await _smsService.SendCallAsync(call, dispatch, departmentNumber, departmentId, profile, call.Address, payment);
+ // Caller-resolved address wins for an unsanitized channel (same precedence as the
+ // cancellation path); a sanitized channel gets no address at all.
+ await _smsService.SendCallAsync(smsCall, dispatch, departmentNumber, departmentId, profile,
+ ReferenceEquals(smsCall, call) ? (address ?? smsCall.Address) : null, payment);
}
}
@@ -274,7 +293,7 @@ await _chatbotOutboundService.SendToUserAsync(dispatch.UserId, departmentId,
{
if (profile == null || profile.EmailVerified.IsContactMethodAllowedForSending())
{
- await _emailService.SendCallAsync(call, dispatch, profile);
+ await _emailService.SendCallAsync(emailCall, dispatch, profile);
}
}
@@ -292,7 +311,7 @@ await _chatbotOutboundService.SendToUserAsync(dispatch.UserId, departmentId,
{
if (!Config.SystemBehaviorConfig.DoNotBroadcast || Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId))
- await _outboundVoiceProvider.CommunicateCallAsync(departmentNumber, profile, call);
+ await _outboundVoiceProvider.CommunicateCallAsync(departmentNumber, profile, voiceCall);
}
catch (Exception ex)
{
@@ -306,6 +325,13 @@ await _chatbotOutboundService.SendToUserAsync(dispatch.UserId, departmentId,
public async Task SendUnitCallAsync(Call call, CallDispatchUnit dispatch, string departmentNumber, string address = null)
{
+ // ADP egress: unit push uses the notification-safe view; the pre-resolved address is
+ // protected location data and is dropped whenever the view is sanitized.
+ var safeUnitCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(call.DepartmentId, call, ProtectedDataEgressChannel.Push);
+ if (!ReferenceEquals(safeUnitCall, call))
+ address = null;
+ call = safeUnitCall;
+
var spc = new StandardPushCall();
spc.CallId = call.CallId;
spc.Title = string.Format("Call {0}", call.Name);
@@ -386,22 +412,29 @@ public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, st
if (profile == null)
profile = await _userProfileService.GetProfileByUserIdAsync(dispatch.UserId);
+ // ADP egress: per-channel notification-safe views for the cancellation, resolved before
+ // any template or provider DTO — same contract as the original dispatch.
+ var pushCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Push);
+ var smsCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Sms);
+ var emailCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Email);
+ var pushAddress = ReferenceEquals(pushCall, call) ? address : null;
+
// Send a Push Notification
if (profile == null || profile.SendPush)
{
try
{
var spc = new StandardPushCall();
- spc.CallId = call.CallId;
- spc.Title = string.Format("Dispatch Cancelled - {0}", call.Name);
- spc.Priority = call.Priority;
+ spc.CallId = pushCall.CallId;
+ spc.Title = string.Format("Dispatch Cancelled - {0}", pushCall.Name);
+ spc.Priority = pushCall.Priority;
spc.ActiveCallCount = 1;
spc.DepartmentId = departmentId;
- spc.DepartmentCode = call.Department?.Code;
+ spc.DepartmentCode = pushCall.Department?.Code;
- if (call.CallPriority != null && !String.IsNullOrWhiteSpace(call.CallPriority.Color))
+ if (pushCall.CallPriority != null && !String.IsNullOrWhiteSpace(pushCall.CallPriority.Color))
{
- spc.Color = call.CallPriority.Color;
+ spc.Color = pushCall.CallPriority.Color;
}
else
{
@@ -410,19 +443,19 @@ public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, st
string subTitle = String.Empty;
- if (String.IsNullOrWhiteSpace(address) && !String.IsNullOrWhiteSpace(call.Address))
+ if (String.IsNullOrWhiteSpace(pushAddress) && !String.IsNullOrWhiteSpace(pushCall.Address))
{
- subTitle = call.Address;
+ subTitle = pushCall.Address;
}
- else if (!String.IsNullOrWhiteSpace(address))
+ else if (!String.IsNullOrWhiteSpace(pushAddress))
{
- subTitle = address;
+ subTitle = pushAddress;
}
- else if (!string.IsNullOrEmpty(call.GeoLocationData) && call.GeoLocationData.Length > 1)
+ else if (!string.IsNullOrEmpty(pushCall.GeoLocationData) && pushCall.GeoLocationData.Length > 1)
{
try
{
- string[] points = call.GeoLocationData.Split(char.Parse(","));
+ string[] points = pushCall.GeoLocationData.Split(char.Parse(","));
if (points != null && points.Length == 2)
{
@@ -439,8 +472,8 @@ public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, st
}
else
{
- if (!string.IsNullOrEmpty(call.NatureOfCall))
- spc.SubTitle = call.NatureOfCall.Truncate(200);
+ if (!string.IsNullOrEmpty(pushCall.NatureOfCall))
+ spc.SubTitle = pushCall.NatureOfCall.Truncate(200);
}
if (String.IsNullOrWhiteSpace(spc.SubTitle))
@@ -456,7 +489,7 @@ public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, st
spc.Title = spc.Title.Replace(char.Parse("/"), char.Parse(" "));
spc.SubTitle = spc.SubTitle.Replace(char.Parse("/"), char.Parse(" "));
- await _pushService.PushCall(spc, dispatch.UserId, profile, call.CallPriority);
+ await _pushService.PushCall(spc, dispatch.UserId, profile, pushCall.CallPriority);
}
catch (Exception ex)
{
@@ -472,7 +505,8 @@ public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, st
try
{
var payment = await _subscriptionsService.GetCurrentPaymentForDepartmentAsync(departmentId);
- await _smsService.SendCancelCallAsync(call, dispatch, departmentNumber, departmentId, profile, address ?? call.Address, payment);
+ await _smsService.SendCancelCallAsync(smsCall, dispatch, departmentNumber, departmentId, profile,
+ ReferenceEquals(smsCall, call) ? (address ?? smsCall.Address) : null, payment);
}
catch (Exception ex)
{
@@ -488,7 +522,7 @@ public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, st
{
try
{
- await _emailService.SendCancelCallAsync(call, dispatch, profile);
+ await _emailService.SendCancelCallAsync(emailCall, dispatch, profile);
}
catch (Exception ex)
{
@@ -504,6 +538,12 @@ public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, st
public async Task SendCancelUnitCallAsync(Call call, CallDispatchUnit dispatch, string departmentNumber, string address = null)
{
+ // ADP egress: cancellation pushes are sanitized the same way as the original dispatch.
+ var safeCancelCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(call.DepartmentId, call, ProtectedDataEgressChannel.Push);
+ if (!ReferenceEquals(safeCancelCall, call))
+ address = null;
+ call = safeCancelCall;
+
var spc = new StandardPushCall();
spc.CallId = call.CallId;
spc.Title = string.Format("Dispatch Cancelled - {0}", call.Name);
@@ -773,6 +813,38 @@ public async Task SendTroubleAlertAsync(TroubleAlertEvent troubleAlertEven
}
}
+ // ADP egress (plan section 9): trouble alerts carry call fields, member names and
+ // locations. Per-channel safe views; a sanitized channel keeps the unit name (asset
+ // identifier, plaintext in catalog v1) so the alert stays actionable, but loses call
+ // content, addresses, coordinates and the personnel roster. The channel decision is
+ // made even when there is NO call — a call-less trouble alert still carries the unit
+ // location and the personnel roster.
+ var pushCall = call != null ? await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Push) : null;
+ var smsCall = call != null ? await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Sms) : null;
+ var emailCall = call != null ? await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Email) : null;
+ var pushSanitized = call != null
+ ? !ReferenceEquals(pushCall, call)
+ : await _protectedProjectionService.IsChannelSanitizedAsync(departmentId, ProtectedDataEgressChannel.Push);
+ var smsSanitized = call != null
+ ? !ReferenceEquals(smsCall, call)
+ : await _protectedProjectionService.IsChannelSanitizedAsync(departmentId, ProtectedDataEgressChannel.Sms);
+ var emailSanitized = call != null
+ ? !ReferenceEquals(emailCall, call)
+ : await _protectedProjectionService.IsChannelSanitizedAsync(departmentId, ProtectedDataEgressChannel.Email);
+
+ var emailEvent = troubleAlertEvent;
+ if (emailSanitized)
+ {
+ emailEvent = new TroubleAlertEvent
+ {
+ UnitId = troubleAlertEvent.UnitId,
+ CallId = troubleAlertEvent.CallId,
+ UserId = troubleAlertEvent.UserId,
+ DepartmentId = troubleAlertEvent.DepartmentId,
+ TimeStamp = troubleAlertEvent.TimeStamp
+ };
+ }
+
foreach (var recipient in recipients)
{
if (!await CanSendToUser(recipient.UserId, departmentId))
@@ -783,17 +855,17 @@ public async Task SendTroubleAlertAsync(TroubleAlertEvent troubleAlertEven
{
var spc = new StandardPushCall();
- if (call != null)
- spc.CallId = call.CallId;
+ if (pushCall != null)
+ spc.CallId = pushCall.CallId;
spc.Title = string.Format("TROUBLE ALERT for {0}", unit.Name);
spc.Priority = (int)CallPriority.Emergency;
spc.ActiveCallCount = 1;
spc.DepartmentId = departmentId;
- spc.DepartmentCode = call.Department?.Code;
+ spc.DepartmentCode = pushCall?.Department?.Code;
string subTitle = String.Empty;
- if (!String.IsNullOrWhiteSpace(unitAddress))
+ if (!pushSanitized && !String.IsNullOrWhiteSpace(unitAddress))
{
spc.Title = string.Format("TROUBLE ALERT for {0} at {1}", unit.Name, unitAddress);
}
@@ -817,7 +889,7 @@ public async Task SendTroubleAlertAsync(TroubleAlertEvent troubleAlertEven
{
try
{
- _smsService.SendTroubleAlert(unit, call, unitAddress, departmentNumber, departmentId, recipient);
+ _smsService.SendTroubleAlert(unit, smsCall, smsSanitized ? null : unitAddress, departmentNumber, departmentId, recipient);
}
catch (Exception ex)
{
@@ -833,7 +905,9 @@ public async Task SendTroubleAlertAsync(TroubleAlertEvent troubleAlertEven
{
try
{
- await _emailService.SendTroubleAlert(troubleAlertEvent, unit, call, callAddress, unitAddress, personnelNames, recipient);
+ await _emailService.SendTroubleAlert(emailEvent, unit, emailCall,
+ emailSanitized ? null : callAddress, emailSanitized ? null : unitAddress,
+ emailSanitized ? "Protected — sign in to Resgrid" : personnelNames, recipient);
}
catch (Exception ex)
{
diff --git a/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs b/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs
new file mode 100644
index 000000000..5cec23351
--- /dev/null
+++ b/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs
@@ -0,0 +1,496 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// The real ADP bulk migration engine (plan sections 19.3–19.4). Walks the code-reviewed table
+ /// bindings in bounded transactional batches with durable cursors in
+ /// DepartmentDataProtectionMigrations. Double-encryption guard: the encrypt path validates any
+ /// value already carrying an envelope against this department's AAD — a matching envelope counts
+ /// as already-protected and moves on; a foreign or corrupt one halts the run with a value-free
+ /// error and is never re-encrypted. The decrypt path passes plaintext through untouched and
+ /// counts the anomaly. DEKs are unwrapped through IKeyWrappingProvider (broker-backed in
+ /// production; LocalDev for synthetic testing; the app tier's NotConfigured provider makes every
+ /// run fail closed with kms_unavailable), held pinned, and zeroed before return. No plaintext is
+ /// ever staged in temp tables, files, or queues.
+ ///
+ public class DepartmentDataMigrationEngine : IDepartmentDataMigrationEngine
+ {
+ private static readonly IReadOnlyList Bindings = AdpTableBindings.V1;
+
+ private readonly IDepartmentDataProtectionBulkRepository _bulkRepository;
+ private readonly IDepartmentDataProtectionMigrationRepository _migrationRepository;
+ private readonly IDepartmentKeyService _keyService;
+ private readonly IKeyWrappingProvider _keyWrappingProvider;
+ private readonly IProtectedFieldCryptoService _cryptoService;
+
+ public DepartmentDataMigrationEngine(IDepartmentDataProtectionBulkRepository bulkRepository,
+ IDepartmentDataProtectionMigrationRepository migrationRepository, IDepartmentKeyService keyService,
+ IKeyWrappingProvider keyWrappingProvider, IProtectedFieldCryptoService cryptoService)
+ {
+ _bulkRepository = bulkRepository;
+ _migrationRepository = migrationRepository;
+ _keyService = keyService;
+ _keyWrappingProvider = keyWrappingProvider;
+ _cryptoService = cryptoService;
+ }
+
+ ///
+ /// False where the key wrapping provider is the fail-closed placeholder (Web/API/worker
+ /// hosts): the coordinator then SKIPS nights instead of opening a window that can only fail
+ /// with kms_unavailable — queued departments wait for a host with a real KMS adapter (the
+ /// Protected Data Broker) rather than being marked Failed by a host that can never succeed.
+ ///
+ public bool IsAvailable => !(_keyWrappingProvider is NotConfiguredKeyWrappingProvider);
+
+ public async Task RunEncryptionNightAsync(AdpMigrationNightContext context, CancellationToken cancellationToken)
+ {
+ var keyVersion = context.TargetKeyVersion ?? 0;
+ var keyRow = keyVersion > 0
+ ? await _keyService.GetKeyByVersionAsync(context.DepartmentId, keyVersion)
+ : await _keyService.GetActiveKeyAsync(context.DepartmentId);
+ if (keyRow == null)
+ return AdpMigrationNightResult.Failed("key_unavailable");
+
+ // Rows already enveloped may reference an EARLIER key version (a run that failed after
+ // encrypting part of the table, followed by a re-provision that minted a new version).
+ // Validation of those envelopes must use the version that wrote them — validating with
+ // the target DEK would read every such row as a foreign envelope and halt the run with
+ // no retry able to clear it.
+ var deks = new Dictionary();
+
+ async Task ResolveDekAsync(int version)
+ {
+ if (deks.TryGetValue(version, out var cached))
+ return cached;
+
+ var versionRow = await _keyService.GetKeyByVersionAsync(context.DepartmentId, version);
+ if (versionRow == null)
+ return null;
+
+ var unwrapped = await _keyWrappingProvider.UnwrapDataKeyAsync(context.DepartmentId, versionRow.WrappedKey, cancellationToken);
+ deks[version] = unwrapped;
+ return unwrapped;
+ }
+
+ try
+ {
+ deks[keyRow.Version] = await _keyWrappingProvider.UnwrapDataKeyAsync(context.DepartmentId, keyRow.WrappedKey, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, $"ADP engine could not unwrap the DEK for department {context.DepartmentId}; failing closed.");
+ return AdpMigrationNightResult.Failed("kms_unavailable");
+ }
+
+ try
+ {
+ var targetDek = deks[keyRow.Version];
+ return await RunNightAsync(context, isEncrypting: true,
+ (spec, row, updates) => EncryptRowColumnAsync(ResolveDekAsync, targetDek, keyRow.Version, context, spec, row, updates),
+ cancellationToken);
+ }
+ finally
+ {
+ foreach (var dek in deks.Values)
+ CryptographicOperations.ZeroMemory(dek);
+ }
+ }
+
+ public async Task RunDecryptionNightAsync(AdpMigrationNightContext context, CancellationToken cancellationToken)
+ {
+ // Offboarding resolves DEKs lazily per envelope key version (older Retiring versions may
+ // still be referenced by envelopes written before a rotation completed).
+ var deks = new Dictionary();
+
+ async Task ResolveDekAsync(int version)
+ {
+ if (deks.TryGetValue(version, out var cached))
+ return cached;
+
+ var keyRow = await _keyService.GetKeyByVersionAsync(context.DepartmentId, version);
+ if (keyRow == null)
+ return null;
+
+ var unwrapped = await _keyWrappingProvider.UnwrapDataKeyAsync(context.DepartmentId, keyRow.WrappedKey, cancellationToken);
+ deks[version] = unwrapped;
+ return unwrapped;
+ }
+
+ try
+ {
+ return await RunNightAsync(context, isEncrypting: false,
+ (spec, row, updates) => DecryptRowColumnAsync(ResolveDekAsync, context, spec, row, updates),
+ cancellationToken);
+ }
+ catch (Exception ex) when (ex is CryptographicException || ex is InvalidOperationException)
+ {
+ Logging.LogException(ex, $"ADP engine decryption failed for department {context.DepartmentId}; failing closed.");
+ return AdpMigrationNightResult.Failed("kms_unavailable");
+ }
+ finally
+ {
+ foreach (var dek in deks.Values)
+ CryptographicOperations.ZeroMemory(dek);
+ }
+ }
+
+ public async Task VerifyAsync(AdpMigrationNightContext context, CancellationToken cancellationToken)
+ {
+ var enveloped = context.Kind == DepartmentDataProtectionMigrationKind.Offboarding;
+
+ foreach (var binding in Bindings)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ 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);
+
+ if (textResidue > 0 || binaryResidue > 0 || companionResidue > 0)
+ {
+ // Value-free: counts and table only, never content.
+ Logging.LogError($"ADP verification failed for department {context.DepartmentId} table {binding.TableName}: residue text={textResidue} binary={binaryResidue} companion={companionResidue}.");
+ await MarkVerificationAsync(context, DepartmentDataProtectionVerificationState.Failed, complete: false, cancellationToken);
+ return false;
+ }
+ }
+
+ await MarkVerificationAsync(context, DepartmentDataProtectionVerificationState.Passed, complete: true, cancellationToken);
+ return true;
+ }
+
+ private async Task RunNightAsync(AdpMigrationNightContext context, bool isEncrypting,
+ Func, Task> processColumnAsync,
+ CancellationToken cancellationToken)
+ {
+ long nightProcessed = 0;
+ var batchSize = Math.Max(50, Config.DataProtectionConfig.MigrationBatchSize);
+
+ foreach (var binding in Bindings)
+ {
+ var migrationRow = await _migrationRepository.GetActiveByDepartmentAndTableAsync(context.DepartmentId,
+ context.Kind, binding.TableName);
+ if (migrationRow == null)
+ {
+ migrationRow = await _migrationRepository.InsertAsync(new DepartmentDataProtectionMigration
+ {
+ DepartmentId = context.DepartmentId,
+ Kind = (int)context.Kind,
+ CatalogVersion = context.CatalogVersion,
+ TargetKeyVersion = context.TargetKeyVersion,
+ TargetTable = binding.TableName,
+ RowsTotal = await _bulkRepository.CountRowsAsync(binding, context.DepartmentId, cancellationToken),
+ CorrelationId = context.CorrelationId,
+ CreatedOn = DateTime.UtcNow,
+ StartedOn = DateTime.UtcNow
+ }, cancellationToken);
+ }
+
+ var cursor = migrationRow.Cursor;
+
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (DateTime.UtcNow >= context.WindowEndUtc)
+ return AdpMigrationNightResult.WindowClosed(nightProcessed, await ComputePercentCompleteAsync(context));
+
+ var batch = await _bulkRepository.GetBatchAsync(binding, context.DepartmentId, cursor, batchSize, cancellationToken);
+ if (batch.Count == 0)
+ break;
+
+ var updates = new List();
+ long processedDelta = 0, alreadyDelta = 0, anomalousDelta = 0;
+
+ foreach (var row in batch)
+ {
+ var setValues = new Dictionary();
+ var rowAlready = false;
+ var rowAnomalous = false;
+
+ foreach (var spec in binding.Columns)
+ {
+ ColumnOutcome outcome;
+ try
+ {
+ outcome = await processColumnAsync(spec, row, setValues);
+ }
+ catch (CryptographicException)
+ {
+ // Foreign or corrupt envelope: never re-encrypted, never silently
+ // skipped — the run halts at this row with a value-free code.
+ await RecordRunErrorAsync(migrationRow, "foreign_envelope", cancellationToken);
+ return AdpMigrationNightResult.Failed("foreign_envelope");
+ }
+
+ rowAlready |= outcome == ColumnOutcome.AlreadyInTargetState;
+ rowAnomalous |= outcome == ColumnOutcome.Anomalous;
+ }
+
+ if (setValues.Count > 0)
+ {
+ if (!string.IsNullOrEmpty(binding.ProtectedMarkerColumn))
+ setValues[binding.ProtectedMarkerColumn] = isEncrypting;
+
+ updates.Add(new AdpBulkRowUpdate { RowKey = row.RowKey, SetValues = setValues });
+ processedDelta++;
+ }
+ else if (rowAlready)
+ {
+ alreadyDelta++;
+ }
+
+ if (rowAnomalous)
+ anomalousDelta++;
+ }
+
+ cursor = batch[batch.Count - 1].RowKey;
+ await _bulkRepository.ApplyBatchAsync(binding, updates, migrationRow.DepartmentDataProtectionMigrationId,
+ cursor, processedDelta, alreadyDelta, anomalousDelta, cancellationToken);
+ nightProcessed += processedDelta;
+
+ if (context.HeartbeatAsync != null)
+ await context.HeartbeatAsync();
+ }
+ }
+
+ return AdpMigrationNightResult.Completed(nightProcessed);
+ }
+
+ private enum ColumnOutcome
+ {
+ Skipped = 0,
+ Changed = 1,
+ AlreadyInTargetState = 2,
+ Anomalous = 3
+ }
+
+ private async Task EncryptRowColumnAsync(Func> resolveDekAsync,
+ byte[] targetDek, int keyVersion, AdpMigrationNightContext context,
+ AdpColumnSpec spec, AdpBulkFieldRow row, Dictionary setValues)
+ {
+ // 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)
+ {
+ if (!ProtectedDataEnvelope.TryParse(envelope, out _, out var envelopeKeyVersion, out _))
+ throw new CryptographicException("Prefixed value is not a parseable ADP envelope; treating as corrupt.");
+
+ var validationDek = envelopeKeyVersion == keyVersion ? targetDek : await resolveDekAsync(envelopeKeyVersion);
+ if (validationDek == null)
+ throw new CryptographicException("Envelope references an unknown department key version.");
+
+ return validationDek;
+ }
+
+ switch (spec.StorageKind)
+ {
+ case ProtectedFieldStorageKind.Text:
+ {
+ var value = row.Values.TryGetValue(spec.ColumnName, out var raw) ? raw as string : null;
+ if (string.IsNullOrEmpty(value))
+ return ColumnOutcome.Skipped;
+
+ if (ProtectedDataEnvelope.HasEnvelopePrefix(value))
+ {
+ // 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, context.CatalogVersion);
+ return ColumnOutcome.AlreadyInTargetState;
+ }
+
+ setValues[spec.ColumnName] = _cryptoService.EncryptText(targetDek, keyVersion, value,
+ context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion);
+ return ColumnOutcome.Changed;
+ }
+
+ case ProtectedFieldStorageKind.Binary:
+ {
+ var value = row.Values.TryGetValue(spec.ColumnName, out var raw) ? raw as byte[] : null;
+ if (value == null || value.Length == 0)
+ return ColumnOutcome.Skipped;
+
+ if (_cryptoService.IsBinaryEnveloped(value))
+ {
+ if (!_cryptoService.TryGetBinaryEnvelopeKeyVersion(value, out var envelopeKeyVersion))
+ throw new CryptographicException("Prefixed blob is not a parseable rgdpb envelope; treating as corrupt.");
+
+ var validationDek = envelopeKeyVersion == keyVersion ? targetDek : await resolveDekAsync(envelopeKeyVersion);
+ if (validationDek == null)
+ throw new CryptographicException("Envelope references an unknown department key version.");
+
+ _cryptoService.DecryptBinary(validationDek, value, context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion);
+ return ColumnOutcome.AlreadyInTargetState;
+ }
+
+ setValues[spec.ColumnName] = _cryptoService.EncryptBinary(targetDek, keyVersion, value,
+ context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion);
+ return ColumnOutcome.Changed;
+ }
+
+ case ProtectedFieldStorageKind.CompanionColumn:
+ {
+ var typed = row.Values.TryGetValue(spec.ColumnName, out var rawTyped) ? rawTyped : null;
+ var companion = row.Values.TryGetValue(spec.CompanionColumn, out var rawCompanion) ? rawCompanion as string : null;
+
+ if (typed != null)
+ {
+ var invariant = Convert.ToString(typed, CultureInfo.InvariantCulture);
+ setValues[spec.CompanionColumn] = _cryptoService.EncryptText(targetDek, keyVersion, invariant,
+ context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion);
+ setValues[spec.ColumnName] = null;
+ return ColumnOutcome.Changed;
+ }
+
+ if (!string.IsNullOrEmpty(companion))
+ {
+ var validationDek = await ValidationDekForTextAsync(companion);
+ _cryptoService.DecryptText(validationDek, companion, context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion);
+ return ColumnOutcome.AlreadyInTargetState;
+ }
+
+ return ColumnOutcome.Skipped;
+ }
+
+ default:
+ return ColumnOutcome.Skipped;
+ }
+ }
+
+ private async Task DecryptRowColumnAsync(Func> resolveDekAsync,
+ AdpMigrationNightContext context, AdpColumnSpec spec, AdpBulkFieldRow row, Dictionary setValues)
+ {
+ switch (spec.StorageKind)
+ {
+ case ProtectedFieldStorageKind.Text:
+ {
+ var value = row.Values.TryGetValue(spec.ColumnName, out var raw) ? raw as string : null;
+ if (string.IsNullOrEmpty(value))
+ return ColumnOutcome.Skipped;
+
+ if (!ProtectedDataEnvelope.TryParse(value, out _, out var envelopeKeyVersion, out _))
+ {
+ // Plaintext reaching the decrypt path passes through untouched; the anomaly is
+ // counted, never "decrypted" into garbage (plan section 19.4).
+ return ProtectedDataEnvelope.HasEnvelopePrefix(value)
+ ? throw new CryptographicException("Corrupt envelope on the decrypt path.")
+ : ColumnOutcome.Anomalous;
+ }
+
+ var dek = await resolveDekAsync(envelopeKeyVersion);
+ if (dek == null)
+ throw new InvalidOperationException($"No key row for envelope version {envelopeKeyVersion}.");
+
+ setValues[spec.ColumnName] = _cryptoService.DecryptText(dek, value,
+ context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion);
+ return ColumnOutcome.Changed;
+ }
+
+ case ProtectedFieldStorageKind.Binary:
+ {
+ var value = row.Values.TryGetValue(spec.ColumnName, out var raw) ? raw as byte[] : null;
+ if (value == null || value.Length == 0)
+ return ColumnOutcome.Skipped;
+
+ if (!_cryptoService.TryGetBinaryEnvelopeKeyVersion(value, out var envelopeKeyVersion))
+ return _cryptoService.IsBinaryEnveloped(value)
+ ? throw new CryptographicException("Corrupt binary envelope on the decrypt path.")
+ : ColumnOutcome.Anomalous;
+
+ var dek = await resolveDekAsync(envelopeKeyVersion);
+ if (dek == null)
+ throw new InvalidOperationException($"No key row for envelope version {envelopeKeyVersion}.");
+
+ setValues[spec.ColumnName] = _cryptoService.DecryptBinary(dek, value,
+ context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion);
+ return ColumnOutcome.Changed;
+ }
+
+ case ProtectedFieldStorageKind.CompanionColumn:
+ {
+ var companion = row.Values.TryGetValue(spec.CompanionColumn, out var rawCompanion) ? rawCompanion as string : null;
+ if (string.IsNullOrEmpty(companion))
+ return ColumnOutcome.Skipped;
+
+ if (!ProtectedDataEnvelope.TryParse(companion, out _, out var envelopeKeyVersion, out _))
+ return ColumnOutcome.Anomalous;
+
+ var dek = await resolveDekAsync(envelopeKeyVersion);
+ if (dek == null)
+ throw new InvalidOperationException($"No key row for envelope version {envelopeKeyVersion}.");
+
+ var plaintext = _cryptoService.DecryptText(dek, companion,
+ context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion);
+ setValues[spec.ColumnName] = decimal.Parse(plaintext, CultureInfo.InvariantCulture);
+ setValues[spec.CompanionColumn] = null;
+ return ColumnOutcome.Changed;
+ }
+
+ default:
+ return ColumnOutcome.Skipped;
+ }
+ }
+
+ private async Task RecordRunErrorAsync(DepartmentDataProtectionMigration migrationRow, string errorCode,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ migrationRow.LastErrorCode = errorCode;
+ migrationRow.Attempts += 1;
+ await _migrationRepository.SaveOrUpdateAsync(migrationRow, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, "ADP engine could not record the run error code.");
+ }
+ }
+
+ private async Task ComputePercentCompleteAsync(AdpMigrationNightContext context)
+ {
+ try
+ {
+ var rows = await _migrationRepository.GetActiveByDepartmentIdAsync(context.DepartmentId, context.Kind);
+ var total = rows.Sum(r => r.RowsTotal);
+ if (total <= 0)
+ return null;
+
+ var done = rows.Sum(r => r.RowsProcessed + r.RowsAlreadyProtected);
+ return (int)Math.Min(100, done * 100 / total);
+ }
+ catch (Exception ex)
+ {
+ // Progress is advisory; the night result stands either way — but leave a trace.
+ Logging.LogException(ex, $"ADP engine could not compute percent complete for department {context.DepartmentId}.");
+ return null;
+ }
+ }
+
+ private async Task MarkVerificationAsync(AdpMigrationNightContext context,
+ DepartmentDataProtectionVerificationState state, bool complete, CancellationToken cancellationToken)
+ {
+ var rows = await _migrationRepository.GetActiveByDepartmentIdAsync(context.DepartmentId, context.Kind);
+ foreach (var row in rows)
+ {
+ row.VerificationState = (int)state;
+ if (complete)
+ row.CompletedOn = DateTime.UtcNow;
+ await _migrationRepository.SaveOrUpdateAsync(row, cancellationToken);
+ }
+ }
+
+ }
+}
diff --git a/Core/Resgrid.Services/DepartmentDataProtectionService.cs b/Core/Resgrid.Services/DepartmentDataProtectionService.cs
new file mode 100644
index 000000000..5449e910e
--- /dev/null
+++ b/Core/Resgrid.Services/DepartmentDataProtectionService.cs
@@ -0,0 +1,531 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Advanced Data Protection policy/state orchestration. See
+ /// for the contract. Enforces the enrollment gates
+ /// server-side (managing member, paid plan, active ADP addon, fresh authoritative global-gate
+ /// evaluation) and owns the queue/cancel/offboarding-schedule edges of the durable state machine;
+ /// every transition beyond those is made by the ADP migration worker. No cryptography here.
+ ///
+ public class DepartmentDataProtectionService : IDepartmentDataProtectionService
+ {
+ private const string PolicyCacheKey = "AdpPolicy_{0}";
+ private const string EgressCacheKey = "AdpEgress_{0}";
+ private static readonly TimeSpan CacheLength = TimeSpan.FromMinutes(5);
+
+ private readonly IDepartmentDataProtectionPolicyRepository _policyRepository;
+ private readonly IDepartmentProtectedDataEgressPolicyRepository _egressPolicyRepository;
+ private readonly IDepartmentsService _departmentsService;
+ private readonly IFeatureToggleService _featureToggleService;
+ private readonly ISubscriptionsService _subscriptionsService;
+ private readonly ICacheProvider _cacheProvider;
+
+ public DepartmentDataProtectionService(IDepartmentDataProtectionPolicyRepository policyRepository,
+ IDepartmentProtectedDataEgressPolicyRepository egressPolicyRepository, IDepartmentsService departmentsService,
+ IFeatureToggleService featureToggleService, ISubscriptionsService subscriptionsService,
+ ICacheProvider cacheProvider)
+ {
+ _policyRepository = policyRepository;
+ _egressPolicyRepository = egressPolicyRepository;
+ _departmentsService = departmentsService;
+ _featureToggleService = featureToggleService;
+ _subscriptionsService = subscriptionsService;
+ _cacheProvider = cacheProvider;
+ }
+
+ public async Task GetPolicyByDepartmentIdAsync(int departmentId, bool bypassCache = false)
+ {
+ async Task getPolicy()
+ {
+ return await _policyRepository.GetByDepartmentIdAsync(departmentId);
+ }
+
+ if (!bypassCache && Config.SystemBehaviorConfig.CacheEnabled)
+ {
+ var cached = await _cacheProvider.RetrieveAsync(
+ string.Format(PolicyCacheKey, departmentId), getPolicy, CacheLength);
+
+ // Guard against blank-entity cache poisoning; an empty payload must read as "no policy".
+ if (cached == null || cached.DepartmentDataProtectionPolicyId <= 0)
+ return null;
+
+ return cached;
+ }
+
+ return await getPolicy();
+ }
+
+ public async Task GetStateAsync(int departmentId, bool bypassCache = false)
+ {
+ var policy = await GetPolicyByDepartmentIdAsync(departmentId, bypassCache);
+ return policy == null ? DepartmentDataProtectionState.Disabled : (DepartmentDataProtectionState)policy.State;
+ }
+
+ public async Task ShouldEncryptNewWritesAsync(int departmentId)
+ {
+ var policy = await GetPolicyByDepartmentIdAsync(departmentId);
+ if (policy == null)
+ return false;
+
+ switch ((DepartmentDataProtectionState)policy.State)
+ {
+ case DepartmentDataProtectionState.Encrypting:
+ case DepartmentDataProtectionState.Enabled:
+ case DepartmentDataProtectionState.Rotating:
+ case DepartmentDataProtectionState.OffboardingScheduled:
+ return true;
+
+ case DepartmentDataProtectionState.Verifying:
+ // Enrollment/rotation verification still encrypts; offboarding verification is past
+ // the decrypt pass and new writes stay plaintext.
+ return policy.ActiveMigrationKind != (int)DepartmentDataProtectionMigrationKind.Offboarding;
+
+ case DepartmentDataProtectionState.Failed:
+ // A failed run resumes from its cursor. Failures on the enrollment/rotation side keep
+ // encrypting so the migrated portion never regresses; failures while offboarding keep
+ // writing plaintext so the decrypt backlog only shrinks.
+ return policy.ActiveMigrationKind != (int)DepartmentDataProtectionMigrationKind.Offboarding;
+
+ default:
+ return false;
+ }
+ }
+
+ public async Task IsProtectionEnforcedAsync(int departmentId)
+ {
+ var state = await GetStateAsync(departmentId);
+ return state == DepartmentDataProtectionState.Enabled
+ || state == DepartmentDataProtectionState.Rotating
+ || state == DepartmentDataProtectionState.OffboardingScheduled;
+ }
+
+ public async Task QueueEnrollmentAsync(int departmentId, string requestingUserId,
+ string acknowledgementsJson, string windowStartLocal, string windowEndLocal, string windowTimeZone,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var managingCheck = await VerifyManagingMemberAsync(departmentId, requestingUserId);
+ if (managingCheck != null)
+ return managingCheck.Value;
+
+ // Paid plan required; the Billing API can return null (empty payload) — treat as free.
+ var plan = await _subscriptionsService.GetCurrentPlanForDepartmentAsync(departmentId, byPassCache: true);
+ if (plan == null || plan.Cost <= 0)
+ return DepartmentDataProtectionEnrollmentResult.PlanRequired;
+
+ if (!await HasActiveAdpAddonAsync(departmentId))
+ return DepartmentDataProtectionEnrollmentResult.AddonRequired;
+
+ // Fresh authoritative global-gate evaluation, performed immediately before commit.
+ // Amended 2026-08-26: the flag is a global admission switch — no targeting, no
+ // percentage rollout, no department overrides. Any error fails closed.
+ FeatureFlag gate;
+ try
+ {
+ gate = await _featureToggleService.GetFlagByKeyAsync(FeatureFlagKeys.DepartmentProtectedDataEnrollment, bypassCache: true);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, $"ADP enrollment gate evaluation failed for department {departmentId}; denying enrollment (fail closed)");
+ return DepartmentDataProtectionEnrollmentResult.FeatureNotAvailable;
+ }
+
+ if (gate == null || gate.IsArchived || !gate.IsEnabledGlobally)
+ return DepartmentDataProtectionEnrollmentResult.FeatureNotAvailable;
+
+ var policy = await _policyRepository.GetByDepartmentIdAsync(departmentId);
+ if (policy != null && (DepartmentDataProtectionState)policy.State != DepartmentDataProtectionState.Disabled)
+ return DepartmentDataProtectionEnrollmentResult.InvalidState;
+
+ // A queued migration whose window time zone never resolves would wait forever (the
+ // worker reads an unresolvable zone as closed). Resolve NOW and persist the
+ // canonical id: the explicit wizard selection first, then the department's own time
+ // zone; neither resolvable = reject rather than queue a permanent stall.
+ string resolvedWindowTimeZone;
+ if (!string.IsNullOrWhiteSpace(windowTimeZone))
+ {
+ if (!TryResolveWindowTimeZone(windowTimeZone, out resolvedWindowTimeZone))
+ return DepartmentDataProtectionEnrollmentResult.InvalidWindow;
+ }
+ else
+ {
+ var department = await _departmentsService.GetDepartmentByIdAsync(departmentId);
+ if (!TryResolveWindowTimeZone(department?.TimeZone, out resolvedWindowTimeZone))
+ return DepartmentDataProtectionEnrollmentResult.InvalidWindow;
+ }
+
+ var utcNow = DateTime.UtcNow;
+ var evaluationRecord = JsonConvert.SerializeObject(new
+ {
+ flagKey = FeatureFlagKeys.DepartmentProtectedDataEnrollment,
+ isEnabledGlobally = gate.IsEnabledGlobally,
+ evaluationSource = "GetFlagByKeyAsync(bypassCache)",
+ evaluatedOnUtc = utcNow,
+ requestingUserId,
+ correlationId = Guid.NewGuid().ToString("N")
+ });
+
+ if (policy == null)
+ {
+ policy = new DepartmentDataProtectionPolicy
+ {
+ DepartmentId = departmentId,
+ State = (int)DepartmentDataProtectionState.EnrollmentQueued,
+ ActiveMigrationKind = (int)DepartmentDataProtectionMigrationKind.Enrollment,
+ StepUpWindowMinutes = Config.DataProtectionConfig.StepUpWindowDefaultMinutes,
+ AcknowledgementsJson = acknowledgementsJson,
+ AcknowledgedByUserId = requestingUserId,
+ AcknowledgedOn = utcNow,
+ EnrollmentFlagEvaluationJson = evaluationRecord,
+ MigrationWindowStartLocal = string.IsNullOrWhiteSpace(windowStartLocal) ? Config.DataProtectionConfig.MigrationWindowDefaultStartLocal : windowStartLocal,
+ MigrationWindowEndLocal = string.IsNullOrWhiteSpace(windowEndLocal) ? Config.DataProtectionConfig.MigrationWindowDefaultEndLocal : windowEndLocal,
+ MigrationWindowTimeZone = resolvedWindowTimeZone,
+ CreatedOn = utcNow,
+ CreatedByUserId = requestingUserId
+ };
+
+ // The unique DepartmentId index turns a concurrent double-enroll into a DbException
+ // on one side; that caller re-reads a non-Disabled row and reports InvalidState.
+ try
+ {
+ await _policyRepository.InsertAsync(policy, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ // Logged so operators can tell a lost enroll race from a real fault
+ // (connectivity, mapping) that also lands here.
+ Logging.LogException(ex, $"ADP enrollment insert failed for department {departmentId}; reporting InvalidState");
+ await InvalidateProtectionCacheAsync(departmentId);
+ return DepartmentDataProtectionEnrollmentResult.InvalidState;
+ }
+ }
+ else
+ {
+ // State already verified Disabled above; the CAS transition still closes the race
+ // against a concurrent enroll command that committed since that read.
+ var rows = await _policyRepository.TryTransitionStateAsync(departmentId,
+ DepartmentDataProtectionState.Disabled, DepartmentDataProtectionState.EnrollmentQueued,
+ (int)DepartmentDataProtectionMigrationKind.Enrollment, requestingUserId, cancellationToken);
+ if (rows == 0)
+ return DepartmentDataProtectionEnrollmentResult.InvalidState;
+
+ policy.State = (int)DepartmentDataProtectionState.EnrollmentQueued;
+ policy.ActiveMigrationKind = (int)DepartmentDataProtectionMigrationKind.Enrollment;
+ policy.AcknowledgementsJson = acknowledgementsJson;
+ policy.AcknowledgedByUserId = requestingUserId;
+ policy.AcknowledgedOn = utcNow;
+ policy.EnrollmentFlagEvaluationJson = evaluationRecord;
+ policy.MigrationWindowStartLocal = string.IsNullOrWhiteSpace(windowStartLocal) ? Config.DataProtectionConfig.MigrationWindowDefaultStartLocal : windowStartLocal;
+ policy.MigrationWindowEndLocal = string.IsNullOrWhiteSpace(windowEndLocal) ? Config.DataProtectionConfig.MigrationWindowDefaultEndLocal : windowEndLocal;
+ policy.MigrationWindowTimeZone = resolvedWindowTimeZone;
+ policy.UpdatedOn = utcNow;
+ policy.UpdatedByUserId = requestingUserId;
+ await _policyRepository.SaveOrUpdateAsync(policy, cancellationToken);
+ }
+
+ await InvalidateProtectionCacheAsync(departmentId);
+ return DepartmentDataProtectionEnrollmentResult.Queued;
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, $"ADP QueueEnrollmentAsync failed for department {departmentId}");
+ return DepartmentDataProtectionEnrollmentResult.Failed;
+ }
+ }
+
+ public async Task CancelQueuedEnrollmentAsync(int departmentId,
+ string requestingUserId, CancellationToken cancellationToken = default)
+ {
+ var managingCheck = await VerifyManagingMemberAsync(departmentId, requestingUserId);
+ if (managingCheck != null)
+ return managingCheck.Value;
+
+ var rows = await _policyRepository.TryTransitionStateAsync(departmentId,
+ DepartmentDataProtectionState.EnrollmentQueued, DepartmentDataProtectionState.Disabled,
+ null, requestingUserId, cancellationToken);
+
+ await InvalidateProtectionCacheAsync(departmentId);
+ return rows > 0 ? DepartmentDataProtectionEnrollmentResult.Queued : DepartmentDataProtectionEnrollmentResult.InvalidState;
+ }
+
+ public async Task ScheduleOffboardingAsync(int departmentId,
+ DepartmentDataProtectionOffboardingSource source, DateTime effectiveOnUtc,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var policy = await _policyRepository.GetByDepartmentIdAsync(departmentId);
+ if (policy == null)
+ return DepartmentDataProtectionEnrollmentResult.InvalidState;
+
+ // A cancellation while still queued simply dequeues at no data cost.
+ if ((DepartmentDataProtectionState)policy.State == DepartmentDataProtectionState.EnrollmentQueued)
+ {
+ var dequeued = await _policyRepository.TryTransitionStateAsync(departmentId,
+ DepartmentDataProtectionState.EnrollmentQueued, DepartmentDataProtectionState.Disabled,
+ null, "system:billing", cancellationToken);
+ await InvalidateProtectionCacheAsync(departmentId);
+ return dequeued > 0 ? DepartmentDataProtectionEnrollmentResult.Queued : DepartmentDataProtectionEnrollmentResult.InvalidState;
+ }
+
+ // Mid-enrollment cancellations are NOT scheduled here: the enrollment completes to
+ // Enabled first (plan section 21.3) and the worker then re-applies the pending billing
+ // state. Only a plain Enabled department schedules offboarding.
+ var rows = await _policyRepository.TryTransitionStateAsync(departmentId,
+ DepartmentDataProtectionState.Enabled, DepartmentDataProtectionState.OffboardingScheduled,
+ null, "system:billing", cancellationToken);
+ if (rows == 0)
+ return DepartmentDataProtectionEnrollmentResult.InvalidState;
+
+ var updated = await _policyRepository.GetByDepartmentIdAsync(departmentId);
+ if (updated != null)
+ {
+ updated.OffboardingEffectiveOn = effectiveOnUtc;
+ updated.OffboardingSource = (int)source;
+ updated.UpdatedOn = DateTime.UtcNow;
+ updated.UpdatedByUserId = "system:billing";
+ await _policyRepository.SaveOrUpdateAsync(updated, cancellationToken);
+ }
+
+ await InvalidateProtectionCacheAsync(departmentId);
+ return DepartmentDataProtectionEnrollmentResult.Queued;
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, $"ADP ScheduleOffboardingAsync failed for department {departmentId}");
+ return DepartmentDataProtectionEnrollmentResult.Failed;
+ }
+ }
+
+ public async Task RevokeOffboardingAsync(int departmentId,
+ string requestingUserId, CancellationToken cancellationToken = default)
+ {
+ var managingCheck = await VerifyManagingMemberAsync(departmentId, requestingUserId);
+ if (managingCheck != null)
+ return managingCheck.Value;
+
+ var rows = await _policyRepository.TryTransitionStateAsync(departmentId,
+ DepartmentDataProtectionState.OffboardingScheduled, DepartmentDataProtectionState.Enabled,
+ null, requestingUserId, cancellationToken);
+ if (rows == 0)
+ {
+ await InvalidateProtectionCacheAsync(departmentId);
+ return DepartmentDataProtectionEnrollmentResult.InvalidState;
+ }
+
+ var policy = await _policyRepository.GetByDepartmentIdAsync(departmentId);
+ if (policy != null)
+ {
+ policy.OffboardingEffectiveOn = null;
+ policy.OffboardingSource = null;
+ policy.UpdatedOn = DateTime.UtcNow;
+ policy.UpdatedByUserId = requestingUserId;
+ await _policyRepository.SaveOrUpdateAsync(policy, cancellationToken);
+ }
+
+ await InvalidateProtectionCacheAsync(departmentId);
+ return DepartmentDataProtectionEnrollmentResult.Queued;
+ }
+
+ public async Task GetEgressPolicyByDepartmentIdAsync(int departmentId, bool bypassCache = false)
+ {
+ async Task getEgressPolicy()
+ {
+ return await _egressPolicyRepository.GetByDepartmentIdAsync(departmentId);
+ }
+
+ DepartmentProtectedDataEgressPolicy policy;
+ if (!bypassCache && Config.SystemBehaviorConfig.CacheEnabled)
+ {
+ policy = await _cacheProvider.RetrieveAsync(
+ string.Format(EgressCacheKey, departmentId), getEgressPolicy, CacheLength);
+
+ if (policy != null && policy.DepartmentProtectedDataEgressPolicyId <= 0)
+ policy = null;
+ }
+ else
+ {
+ policy = await getEgressPolicy();
+ }
+
+ // No row = the fail-safe defaults: every channel GenericOnly.
+ return policy ?? new DepartmentProtectedDataEgressPolicy
+ {
+ DepartmentId = departmentId,
+ PushMode = (int)ProtectedDataEgressMode.GenericOnly,
+ EmailMode = (int)ProtectedDataEgressMode.GenericOnly,
+ SmsMode = (int)ProtectedDataEgressMode.GenericOnly,
+ VoiceMode = (int)ProtectedDataEgressMode.GenericOnly,
+ PinChallengeExpiryMinutes = 5,
+ PinMaxAttempts = 3,
+ PinLockoutMinutes = 15
+ };
+ }
+
+ public async Task SaveEgressPolicyAsync(DepartmentProtectedDataEgressPolicy policy,
+ string updatedByUserId, CancellationToken cancellationToken = default)
+ {
+ if (policy == null)
+ throw new ArgumentNullException(nameof(policy));
+
+ // ProtectedAfterPin is a two-step SMS/voice release; push and email have no PIN interaction.
+ if (policy.PushMode == (int)ProtectedDataEgressMode.ProtectedAfterPin ||
+ policy.EmailMode == (int)ProtectedDataEgressMode.ProtectedAfterPin)
+ throw new ArgumentException("ProtectedAfterPin is only valid for the SMS and voice channels.", nameof(policy));
+
+ // Any mode that can emit protected content off-app (plan sections 9.1 and 12) requires
+ // the versioned administrator acknowledgement to be recorded on the policy — otherwise
+ // an unacknowledged save silently enables protected egress.
+ var emitsProtectedContent =
+ policy.PushMode == (int)ProtectedDataEgressMode.AllowProtectedContent ||
+ policy.EmailMode == (int)ProtectedDataEgressMode.AllowProtectedContent ||
+ policy.SmsMode == (int)ProtectedDataEgressMode.AllowProtectedContent ||
+ policy.VoiceMode == (int)ProtectedDataEgressMode.AllowProtectedContent ||
+ policy.SmsMode == (int)ProtectedDataEgressMode.ProtectedAfterPin ||
+ policy.VoiceMode == (int)ProtectedDataEgressMode.ProtectedAfterPin;
+ if (emitsProtectedContent &&
+ (string.IsNullOrWhiteSpace(policy.AcknowledgementVersion) || string.IsNullOrWhiteSpace(policy.AcknowledgedByUserId)))
+ throw new ArgumentException(
+ "Egress modes that emit protected content require a recorded, versioned administrator acknowledgement.",
+ nameof(policy));
+
+ var utcNow = DateTime.UtcNow;
+ if (policy.DepartmentProtectedDataEgressPolicyId <= 0)
+ policy.CreatedOn = utcNow;
+ policy.UpdatedOn = utcNow;
+ policy.UpdatedByUserId = updatedByUserId;
+
+ var saved = await _egressPolicyRepository.SaveOrUpdateAsync(policy, cancellationToken);
+
+ // Egress changes revoke outstanding grants and force send-time re-evaluation.
+ await IncrementPolicyEpochAsync(policy.DepartmentId, updatedByUserId, cancellationToken);
+ await InvalidateProtectionCacheAsync(policy.DepartmentId);
+
+ return saved;
+ }
+
+ public async Task GetEnrollmentPreflightAsync(int departmentId, string requestingUserId,
+ CancellationToken cancellationToken = default)
+ {
+ var preflight = new AdpEnrollmentPreflight();
+
+ var department = await _departmentsService.GetDepartmentByIdAsync(departmentId);
+ preflight.IsManagingMember = department != null && !string.IsNullOrWhiteSpace(requestingUserId) &&
+ string.Equals(department.ManagingUserId, requestingUserId, StringComparison.OrdinalIgnoreCase);
+
+ try
+ {
+ var plan = await _subscriptionsService.GetCurrentPlanForDepartmentAsync(departmentId, byPassCache: true);
+ preflight.HasPaidPlan = plan != null && plan.Cost > 0;
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, $"ADP preflight plan lookup failed for department {departmentId}; reporting no paid plan");
+ }
+
+ preflight.HasActiveAddon = await HasActiveAdpAddonAsync(departmentId);
+
+ try
+ {
+ var gate = await _featureToggleService.GetFlagByKeyAsync(FeatureFlagKeys.DepartmentProtectedDataEnrollment, bypassCache: true);
+ preflight.GateOpen = gate != null && !gate.IsArchived && gate.IsEnabledGlobally;
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, $"ADP preflight gate evaluation failed for department {departmentId}; reporting closed");
+ }
+
+ preflight.StateAllowsEnrollment = await GetStateAsync(departmentId, bypassCache: true) == DepartmentDataProtectionState.Disabled;
+
+ return preflight;
+ }
+
+ public async Task IncrementPolicyEpochAsync(int departmentId, string updatedByUserId, CancellationToken cancellationToken = default)
+ {
+ var epoch = await _policyRepository.IncrementPolicyEpochAsync(departmentId, updatedByUserId, cancellationToken);
+ await InvalidateProtectionCacheAsync(departmentId);
+ return epoch;
+ }
+
+ public async Task InvalidateProtectionCacheAsync(int departmentId)
+ {
+ await _cacheProvider.RemoveAsync(string.Format(PolicyCacheKey, departmentId));
+ await _cacheProvider.RemoveAsync(string.Format(EgressCacheKey, departmentId));
+ }
+
+ ///
+ /// Resolves a wizard-supplied or department time zone to its canonical system id. The worker
+ /// evaluates windows with TimeZoneInfo.FindSystemTimeZoneById, so only ids that resolve
+ /// there may ever be persisted on the policy row.
+ ///
+ private static bool TryResolveWindowTimeZone(string timeZoneId, out string resolvedId)
+ {
+ resolvedId = null;
+ if (string.IsNullOrWhiteSpace(timeZoneId))
+ return false;
+
+ try
+ {
+ resolvedId = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId.Trim()).Id;
+ return true;
+ }
+ catch (TimeZoneNotFoundException)
+ {
+ return false;
+ }
+ catch (InvalidTimeZoneException)
+ {
+ return false;
+ }
+ }
+
+ private async Task VerifyManagingMemberAsync(int departmentId, string requestingUserId)
+ {
+ if (string.IsNullOrWhiteSpace(requestingUserId))
+ return DepartmentDataProtectionEnrollmentResult.NotManagingMember;
+
+ var department = await _departmentsService.GetDepartmentByIdAsync(departmentId);
+ if (department == null)
+ return DepartmentDataProtectionEnrollmentResult.Failed;
+
+ // Only the managing member — holders of ManageDepartmentDataProtection are deliberately
+ // NOT sufficient for enrollment/offboarding/billing commands (plan decision 15).
+ if (!string.Equals(department.ManagingUserId, requestingUserId, StringComparison.OrdinalIgnoreCase))
+ return DepartmentDataProtectionEnrollmentResult.NotManagingMember;
+
+ return null;
+ }
+
+ private async Task HasActiveAdpAddonAsync(int departmentId)
+ {
+ // Provider-level addon resolution (Stripe today; the Billing API DepartmentBillingSummary
+ // ADP block replaces this in workstream A1). Null-safe: a missing/errored billing response
+ // reads as "no addon" — fail closed for enrollment.
+ try
+ {
+ var addons = await _subscriptionsService.GetCurrentPlanAddonsForDepartmentFromStripeAsync(departmentId);
+ if (addons == null)
+ return false;
+
+ return addons.Any(a => a != null && a.AddonType == (int)PlanAddonTypes.ADP && !a.IsCancelled);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, $"ADP addon lookup failed for department {departmentId}; treating as no active addon");
+ return false;
+ }
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/DepartmentKeyService.cs b/Core/Resgrid.Services/DepartmentKeyService.cs
new file mode 100644
index 000000000..7cbe80890
--- /dev/null
+++ b/Core/Resgrid.Services/DepartmentKeyService.cs
@@ -0,0 +1,122 @@
+using System;
+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.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Department DEK version lifecycle. See for the contract.
+ /// Metadata only — wrapped blobs come from the IKeyWrappingProvider and are stored verbatim;
+ /// nothing here can produce plaintext key material.
+ ///
+ public class DepartmentKeyService : IDepartmentKeyService
+ {
+ private readonly IDepartmentDataProtectionKeyRepository _keyRepository;
+ private readonly IKeyWrappingProvider _keyWrappingProvider;
+
+ public DepartmentKeyService(IDepartmentDataProtectionKeyRepository keyRepository,
+ IKeyWrappingProvider keyWrappingProvider)
+ {
+ _keyRepository = keyRepository;
+ _keyWrappingProvider = keyWrappingProvider;
+ }
+
+ public Task GetActiveKeyAsync(int departmentId) =>
+ _keyRepository.GetActiveByDepartmentIdAsync(departmentId);
+
+ public Task GetKeyByVersionAsync(int departmentId, int version) =>
+ _keyRepository.GetByDepartmentAndVersionAsync(departmentId, version);
+
+ public async Task ProvisionNextKeyVersionAsync(int departmentId,
+ CancellationToken cancellationToken = default)
+ {
+ var existing = await _keyRepository.GetAllVersionsByDepartmentIdAsync(departmentId);
+
+ // Idempotent resume: a Pending row is a previous provisioning attempt that crashed before
+ // activation — activate it rather than minting another version; an already-Active newest
+ // version means provisioning completed and this call is a re-run.
+ var newest = existing.OrderByDescending(k => k.Version).FirstOrDefault();
+ if (newest != null && newest.Status == (int)DepartmentDataProtectionKeyStatus.Active)
+ return newest;
+ if (newest != null && newest.Status == (int)DepartmentDataProtectionKeyStatus.Pending)
+ return await ActivateAsync(newest, existing.Where(k => k.Version < newest.Version), cancellationToken);
+
+ var nextVersion = (newest?.Version ?? 0) + 1;
+ var wrapped = await _keyWrappingProvider.GenerateWrappedDataKeyAsync(departmentId, cancellationToken);
+
+ var keyRow = new DepartmentDataProtectionKey
+ {
+ DepartmentId = departmentId,
+ Version = nextVersion,
+ WrappedKey = wrapped.WrappedKeyBase64,
+ ProviderType = wrapped.ProviderType,
+ ProviderKeyReference = wrapped.ProviderKeyReference,
+ ProviderKeyVersion = wrapped.ProviderKeyVersion,
+ Status = (int)DepartmentDataProtectionKeyStatus.Pending,
+ CreatedOn = DateTime.UtcNow
+ };
+
+ // The unique (DepartmentId, Version) index turns a concurrent double-provision into a
+ // database error on one side; that caller re-reads and resumes idempotently. Only
+ // database exceptions enter the re-read path — anything else (KMS faults, cancellation)
+ // propagates with its original cause intact.
+ try
+ {
+ await _keyRepository.InsertAsync(keyRow, cancellationToken);
+ }
+ catch (System.Data.Common.DbException ex)
+ {
+ Logging.LogException(ex, $"ADP key provisioning insert collided for department {departmentId} version {nextVersion}; re-reading");
+ var reread = await _keyRepository.GetByDepartmentAndVersionAsync(departmentId, nextVersion);
+ if (reread == null)
+ throw;
+ keyRow = reread;
+ }
+
+ return await ActivateAsync(keyRow, existing, cancellationToken);
+ }
+
+ public async Task RetireKeyVersionAsync(int departmentId, int version, CancellationToken cancellationToken = default)
+ {
+ var keyRow = await _keyRepository.GetByDepartmentAndVersionAsync(departmentId, version);
+ if (keyRow == null || keyRow.Status != (int)DepartmentDataProtectionKeyStatus.Retiring)
+ return false;
+
+ keyRow.Status = (int)DepartmentDataProtectionKeyStatus.Retired;
+ keyRow.RetiredOn = DateTime.UtcNow;
+ await _keyRepository.SaveOrUpdateAsync(keyRow, cancellationToken);
+ return true;
+ }
+
+ private async Task ActivateAsync(DepartmentDataProtectionKey keyRow,
+ System.Collections.Generic.IEnumerable olderVersions,
+ CancellationToken cancellationToken)
+ {
+ // The NEW version becomes Active first so a concurrent GetActiveKeyAsync never observes
+ // zero Active rows mid-activation (the lookup takes the highest Active version, so the
+ // brief two-Active overlap resolves to the new key). Older versions then move to
+ // Retiring; reads resolve older envelopes through Retiring versions until rotation
+ // re-encryption retires them.
+ if (keyRow.Status != (int)DepartmentDataProtectionKeyStatus.Active)
+ {
+ keyRow.Status = (int)DepartmentDataProtectionKeyStatus.Active;
+ keyRow.ActivatedOn = DateTime.UtcNow;
+ await _keyRepository.SaveOrUpdateAsync(keyRow, cancellationToken);
+ }
+
+ foreach (var older in olderVersions.Where(k => k.Status == (int)DepartmentDataProtectionKeyStatus.Active))
+ {
+ older.Status = (int)DepartmentDataProtectionKeyStatus.Retiring;
+ await _keyRepository.SaveOrUpdateAsync(older, cancellationToken);
+ }
+
+ return keyRow;
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/DepartmentLockService.cs b/Core/Resgrid.Services/DepartmentLockService.cs
new file mode 100644
index 000000000..a9c5671f6
--- /dev/null
+++ b/Core/Resgrid.Services/DepartmentLockService.cs
@@ -0,0 +1,172 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Department operation lock control plane. See for the
+ /// contract. The hot path (IsDepartmentLockedAsync) is a short-TTL cache read; apply/release
+ /// invalidate immediately so enforcement follows lock changes within one cache miss. Expired
+ /// locks stop enforcing the moment ExpiresUtc passes, before any durable sweep runs.
+ ///
+ public class DepartmentLockService : IDepartmentLockService
+ {
+ private const string ActiveLockCacheKey = "DeptOpLock_{0}";
+ private static readonly TimeSpan CacheLength = TimeSpan.FromSeconds(30);
+
+ private readonly IDepartmentOperationLockRepository _departmentOperationLockRepository;
+ private readonly ICacheProvider _cacheProvider;
+
+ public DepartmentLockService(IDepartmentOperationLockRepository departmentOperationLockRepository,
+ ICacheProvider cacheProvider)
+ {
+ _departmentOperationLockRepository = departmentOperationLockRepository;
+ _cacheProvider = cacheProvider;
+ }
+
+ public async Task IsDepartmentLockedAsync(int departmentId)
+ {
+ try
+ {
+ var activeLock = await GetActiveLockAsync(departmentId);
+
+ // A blank cache-poisoned entity (Id 0) or an expired safety valve never enforces.
+ if (activeLock == null || activeLock.DepartmentOperationLockId <= 0)
+ return false;
+
+ return activeLock.ExpiresUtc > DateTime.UtcNow;
+ }
+ catch (Exception ex)
+ {
+ // Fail open by design: the lock exists to protect a migration, but a lock-store outage
+ // must never take dispatch down — dispatch availability beats migration progress. The
+ // migration worker separately refuses to proceed when it cannot verify its own lock.
+ Logging.LogException(ex, $"DepartmentLockService.IsDepartmentLockedAsync failed for department {departmentId}; failing open (unlocked)");
+ return false;
+ }
+ }
+
+ public async Task GetActiveLockAsync(int departmentId, bool bypassCache = false)
+ {
+ async Task getActiveLock()
+ {
+ return await _departmentOperationLockRepository.GetActiveByDepartmentIdAsync(departmentId);
+ }
+
+ if (!bypassCache && Config.SystemBehaviorConfig.CacheEnabled)
+ {
+ var cached = await _cacheProvider.RetrieveAsync(
+ string.Format(ActiveLockCacheKey, departmentId), getActiveLock, CacheLength);
+
+ // Guard against blank-entity cache poisoning: an empty payload deserializes to a
+ // non-null entity with default values, which must read as "no lock".
+ if (cached == null || cached.DepartmentOperationLockId <= 0)
+ return null;
+
+ return cached;
+ }
+
+ return await getActiveLock();
+ }
+
+ public Task> GetAllActiveLocksAsync()
+ {
+ return _departmentOperationLockRepository.GetAllActiveAsync();
+ }
+
+ public async Task ApplyLockAsync(int departmentId, DepartmentOperationLockType lockType,
+ string reason, string correlationId, string appliedByIdentity, DateTime expiresUtc,
+ DateTime? projectedEndUtc, CancellationToken cancellationToken = default)
+ {
+ var utcNow = DateTime.UtcNow;
+ var departmentLock = new DepartmentOperationLock
+ {
+ DepartmentId = departmentId,
+ LockType = (int)lockType,
+ Reason = reason,
+ CorrelationId = correlationId,
+ AppliedUtc = utcNow,
+ AppliedByIdentity = appliedByIdentity,
+ HeartbeatUtc = utcNow,
+ ExpiresUtc = expiresUtc,
+ ProjectedEndUtc = projectedEndUtc
+ };
+
+ var acquired = await _departmentOperationLockRepository.TryAcquireAsync(departmentLock, cancellationToken);
+
+ await InvalidateLockCacheAsync(departmentId);
+
+ return acquired ? departmentLock : null;
+ }
+
+ public async Task HeartbeatAsync(int departmentOperationLockId, DateTime? newExpiresUtc = null,
+ CancellationToken cancellationToken = default)
+ {
+ var rows = await _departmentOperationLockRepository.HeartbeatAsync(departmentOperationLockId,
+ DateTime.UtcNow, newExpiresUtc, cancellationToken);
+
+ // Drop the cached row so readers see the extended expiry: with a short operator-tuned
+ // LockExpirySeconds a stale cached ExpiresUtc could otherwise read as "unlocked" while
+ // migration batches are still running.
+ if (rows > 0 && newExpiresUtc.HasValue)
+ {
+ var lockRow = await _departmentOperationLockRepository.GetByIdAsync(departmentOperationLockId);
+ if (lockRow != null)
+ await InvalidateLockCacheAsync(lockRow.DepartmentId);
+ }
+
+ return rows > 0;
+ }
+
+ public async Task ReleaseLockAsync(int departmentOperationLockId, DepartmentOperationLockReleaseKind kind,
+ string releasedBy, CancellationToken cancellationToken = default)
+ {
+ // The row is fetched first so the department's cache entry can be invalidated after release.
+ var lockRow = await _departmentOperationLockRepository.GetByIdAsync(departmentOperationLockId);
+
+ var rows = await _departmentOperationLockRepository.ReleaseAsync(departmentOperationLockId, kind,
+ releasedBy, DateTime.UtcNow, cancellationToken);
+
+ if (lockRow != null)
+ await InvalidateLockCacheAsync(lockRow.DepartmentId);
+
+ return rows > 0;
+ }
+
+ public async Task> ReleaseExpiredLocksAsync(CancellationToken cancellationToken = default)
+ {
+ var utcNow = DateTime.UtcNow;
+ var released = new List();
+
+ foreach (var activeLock in await _departmentOperationLockRepository.GetAllActiveAsync())
+ {
+ if (activeLock.ExpiresUtc > utcNow)
+ continue;
+
+ var rows = await _departmentOperationLockRepository.ReleaseAsync(activeLock.DepartmentOperationLockId,
+ DepartmentOperationLockReleaseKind.Expired, "system:lock-expiry-sweep", utcNow, cancellationToken);
+
+ if (rows > 0)
+ {
+ released.Add(activeLock);
+ await InvalidateLockCacheAsync(activeLock.DepartmentId);
+ Logging.LogError($"Department operation lock {activeLock.DepartmentOperationLockId} for department {activeLock.DepartmentId} expired with a stale heartbeat and was force-released; the owning migration must be marked Failed at its cursor.");
+ }
+ }
+
+ return released;
+ }
+
+ public async Task InvalidateLockCacheAsync(int departmentId)
+ {
+ await _cacheProvider.RemoveAsync(string.Format(ActiveLockCacheKey, departmentId));
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/LocalDevKeyWrappingProvider.cs b/Core/Resgrid.Services/LocalDevKeyWrappingProvider.cs
new file mode 100644
index 000000000..434bc862b
--- /dev/null
+++ b/Core/Resgrid.Services/LocalDevKeyWrappingProvider.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Config;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+
+namespace Resgrid.Services
+{
+ ///
+ /// SYNTHETIC/NON-PHI TESTING ONLY key wrapping provider (ADP plan section 2.1). Wraps DEKs with
+ /// AES-GCM under a process-local key derived from a development constant — no KMS, no HSM, no
+ /// real protection. The constructor refuses to exist in a production environment, and the
+ /// department id is bound as AAD so even dev wrapping enforces the cross-department failure mode
+ /// the real adapter has.
+ ///
+ public class LocalDevKeyWrappingProvider : IKeyWrappingProvider
+ {
+ private const string DevKeySeed = "resgrid-localdev-key-wrapping-NOT-FOR-PRODUCTION";
+ private static readonly byte[] WrappingKey = SHA256.HashData(Encoding.UTF8.GetBytes(DevKeySeed));
+
+ public LocalDevKeyWrappingProvider()
+ {
+ // Production startup must reject the local development provider (plan sections 2.1, A.14).
+ if (SystemBehaviorConfig.Environment == SystemEnvironment.Prod)
+ throw new InvalidOperationException(
+ "LocalDevKeyWrappingProvider must never run in production. Configure DataProtectionConfig.KeyWrappingProviderType to a real KMS adapter.");
+ }
+
+ public string ProviderType => "LocalDev";
+
+ public Task GenerateWrappedDataKeyAsync(int departmentId, CancellationToken cancellationToken = default)
+ {
+ var dek = new byte[32];
+ RandomNumberGenerator.Fill(dek);
+ try
+ {
+ var nonce = new byte[12];
+ RandomNumberGenerator.Fill(nonce);
+ var tag = new byte[16];
+ var ciphertext = new byte[dek.Length];
+
+ using (var aes = new AesGcm(WrappingKey, 16))
+ aes.Encrypt(nonce, dek, ciphertext, tag, DepartmentAad(departmentId));
+
+ var blob = new byte[nonce.Length + tag.Length + ciphertext.Length];
+ Buffer.BlockCopy(nonce, 0, blob, 0, nonce.Length);
+ Buffer.BlockCopy(tag, 0, blob, nonce.Length, tag.Length);
+ Buffer.BlockCopy(ciphertext, 0, blob, nonce.Length + tag.Length, ciphertext.Length);
+
+ return Task.FromResult(new WrappedDataKey
+ {
+ WrappedKeyBase64 = Convert.ToBase64String(blob),
+ ProviderType = ProviderType,
+ ProviderKeyReference = "localdev",
+ ProviderKeyVersion = 1
+ });
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dek);
+ }
+ }
+
+ public Task UnwrapDataKeyAsync(int departmentId, string wrappedKeyBase64, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(wrappedKeyBase64))
+ throw new ArgumentException("Wrapped key is required.", nameof(wrappedKeyBase64));
+
+ var blob = Convert.FromBase64String(wrappedKeyBase64);
+ if (blob.Length <= 28)
+ throw new CryptographicException("Wrapped key blob is malformed.");
+
+ var nonce = new byte[12];
+ var tag = new byte[16];
+ var ciphertext = new byte[blob.Length - 28];
+ Buffer.BlockCopy(blob, 0, nonce, 0, 12);
+ Buffer.BlockCopy(blob, 12, tag, 0, 16);
+ Buffer.BlockCopy(blob, 28, ciphertext, 0, ciphertext.Length);
+
+ // GC.AllocateArray(pinned) so the caller's ZeroMemory is not defeated by GC compaction copies.
+ var dek = GC.AllocateArray(ciphertext.Length, pinned: true);
+ using (var aes = new AesGcm(WrappingKey, 16))
+ aes.Decrypt(nonce, ciphertext, tag, dek, DepartmentAad(departmentId));
+
+ return Task.FromResult(dek);
+ }
+
+ private static byte[] DepartmentAad(int departmentId) =>
+ Encoding.UTF8.GetBytes($"resgrid-dept:{departmentId}");
+ }
+}
diff --git a/Core/Resgrid.Services/NotConfiguredKeyWrappingProvider.cs b/Core/Resgrid.Services/NotConfiguredKeyWrappingProvider.cs
new file mode 100644
index 000000000..d9dc024b8
--- /dev/null
+++ b/Core/Resgrid.Services/NotConfiguredKeyWrappingProvider.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Config;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Fail-closed placeholder registered when DataProtectionConfig.KeyWrappingProviderType names a
+ /// provider this host does not implement (in the target topology only the Protected Data Broker
+ /// runs a real KMS adapter — Web/API/worker hosts land here by design). Every operation throws:
+ /// protected operations fail closed rather than degrading to a local or null crypto path.
+ ///
+ public class NotConfiguredKeyWrappingProvider : IKeyWrappingProvider
+ {
+ public string ProviderType => DataProtectionConfig.KeyWrappingProviderType;
+
+ public Task GenerateWrappedDataKeyAsync(int departmentId, CancellationToken cancellationToken = default) =>
+ throw Fail();
+
+ public Task UnwrapDataKeyAsync(int departmentId, string wrappedKeyBase64, CancellationToken cancellationToken = default) =>
+ throw Fail();
+
+ private static InvalidOperationException Fail() => new InvalidOperationException(
+ $"Key wrapping provider '{DataProtectionConfig.KeyWrappingProviderType}' is not available on this host. " +
+ "Department key operations run only where the configured KMS adapter is deployed (the Protected Data Broker); protected operations fail closed here.");
+ }
+}
diff --git a/Core/Resgrid.Services/NullDepartmentDataMigrationEngine.cs b/Core/Resgrid.Services/NullDepartmentDataMigrationEngine.cs
new file mode 100644
index 000000000..4f995aceb
--- /dev/null
+++ b/Core/Resgrid.Services/NullDepartmentDataMigrationEngine.cs
@@ -0,0 +1,30 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Fail-closed engine placeholder until the broker-backed engine ships (ADP Phase 2/3). Every
+ /// night run reports Failed with the value-free code "engine_unavailable" — the coordinator marks
+ /// the migration Failed at its cursor, releases the lock, and notifies, exactly as it would for
+ /// any other unrecoverable error. Verification never passes, so no department can reach Enabled
+ /// (or Disabled from an offboarding) without real, verified data movement.
+ ///
+ public class NullDepartmentDataMigrationEngine : IDepartmentDataMigrationEngine
+ {
+ public const string EngineUnavailableErrorCode = "engine_unavailable";
+
+ public bool IsAvailable => false;
+
+ public Task RunEncryptionNightAsync(AdpMigrationNightContext context, CancellationToken cancellationToken) =>
+ Task.FromResult(AdpMigrationNightResult.Failed(EngineUnavailableErrorCode));
+
+ public Task RunDecryptionNightAsync(AdpMigrationNightContext context, CancellationToken cancellationToken) =>
+ Task.FromResult(AdpMigrationNightResult.Failed(EngineUnavailableErrorCode));
+
+ public Task VerifyAsync(AdpMigrationNightContext context, CancellationToken cancellationToken) =>
+ Task.FromResult(false);
+ }
+}
diff --git a/Core/Resgrid.Services/ProtectedDataGrantService.cs b/Core/Resgrid.Services/ProtectedDataGrantService.cs
new file mode 100644
index 000000000..e7f0b7918
--- /dev/null
+++ b/Core/Resgrid.Services/ProtectedDataGrantService.cs
@@ -0,0 +1,298 @@
+using System;
+using System.Collections.Generic;
+using System.IdentityModel.Tokens.Jwt;
+using System.Linq;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using Microsoft.IdentityModel.Tokens;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Protected Data Grant issuance/validation (ADP plan section 3). ES256 compact JWS with the
+ /// section 3.2 claims; the algorithm is pinned — a token presenting any other algorithm is
+ /// invalid regardless of its signature. Issuance requires the signing PFX (identity tier only);
+ /// validation requires only the public certificate (broker and API hosts). Everything fails
+ /// closed: missing key material, parse faults, wrong department, stale policy epoch, or a
+ /// missing scope all deny. The service never logs tokens or claims values beyond identifiers.
+ ///
+ public class ProtectedDataGrantService : IProtectedDataGrantService
+ {
+ private const string DepartmentClaim = "dept";
+ private const string ClientAppClaim = "client_app";
+ private const string PolicyEpochClaim = "policy_epoch";
+ private const string MfaAtClaim = "mfa_at";
+ private const string ScopeClaim = "scope";
+ private const string AmrClaim = "amr";
+
+ private static readonly JwtSecurityTokenHandler TokenHandler = new JwtSecurityTokenHandler();
+
+ // Lazy with ExecutionAndPublication provides the safe publication a hand-rolled
+ // flag+lock does not: a thread that observes the initialized state is guaranteed to observe
+ // the certificate write too (the flag/field pattern could transiently read null on weakly
+ // ordered CPUs and mis-report NotConfigured). Load failures log once and cache null — the
+ // factories never throw, so no exception is cached either.
+ private readonly Lazy _signingCertificate;
+ private readonly Lazy _validationCertificate;
+
+ public ProtectedDataGrantService()
+ : this(LoadSigningCertificateFromConfig, LoadValidationCertificateFromConfig)
+ {
+ }
+
+ /// Test seam: supply certificates directly instead of loading from configured paths.
+ public ProtectedDataGrantService(Func signingCertificateLoader,
+ Func validationCertificateLoader)
+ {
+ if (signingCertificateLoader == null)
+ throw new ArgumentNullException(nameof(signingCertificateLoader));
+ if (validationCertificateLoader == null)
+ throw new ArgumentNullException(nameof(validationCertificateLoader));
+
+ _signingCertificate = new Lazy(
+ () => LoadSigningCertificateSafe(signingCertificateLoader),
+ System.Threading.LazyThreadSafetyMode.ExecutionAndPublication);
+ _validationCertificate = new Lazy(
+ () => LoadValidationCertificateSafe(validationCertificateLoader),
+ System.Threading.LazyThreadSafetyMode.ExecutionAndPublication);
+ }
+
+ public bool CanIssueGrants => GetSigningCertificate() != null;
+
+ public bool CanValidateGrants => GetValidationCertificate() != null;
+
+ public ProtectedDataGrantIssueResult IssueGrant(ProtectedDataGrantIssueRequest request)
+ {
+ if (request == null)
+ throw new ArgumentNullException(nameof(request));
+ if (string.IsNullOrWhiteSpace(request.UserId))
+ throw new ArgumentException("A grant requires a user id.", nameof(request));
+ if (request.DepartmentId <= 0)
+ throw new ArgumentException("A grant requires exactly one department.", nameof(request));
+ if (request.Scopes == null || request.Scopes.Count == 0 || request.Scopes.Any(string.IsNullOrWhiteSpace))
+ throw new ArgumentException("A grant requires at least one non-empty scope.", nameof(request));
+ if (request.PolicyEpoch < 0)
+ throw new ArgumentException("Policy epoch cannot be negative.", nameof(request));
+
+ var signingCertificate = GetSigningCertificate();
+ if (signingCertificate == null)
+ throw new InvalidOperationException(
+ "Protected Data Grant signing is not configured on this host. Grants are issued only by the identity tier (check CanIssueGrants before calling).");
+
+ // Absolute lifetime: floor 1 minute, ceiling the operator maximum (plan section 3.3).
+ var ceiling = Math.Max(1, Config.DataProtectionConfig.StepUpMaximumMinutes);
+ var windowMinutes = Math.Min(Math.Max(1, request.WindowMinutes), ceiling);
+
+ var now = DateTime.UtcNow;
+ var expires = now.AddMinutes(windowMinutes);
+ var grantId = Guid.NewGuid().ToString("N");
+ var mfaAt = request.MfaAtUtc == default ? now : request.MfaAtUtc;
+
+ var claims = new List
+ {
+ new Claim(JwtRegisteredClaimNames.Sub, request.UserId),
+ new Claim(JwtRegisteredClaimNames.Jti, grantId),
+ new Claim(DepartmentClaim, request.DepartmentId.ToString(), ClaimValueTypes.Integer32),
+ 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"),
+ new Claim(ScopeClaim, string.Join(" ", request.Scopes))
+ };
+
+ if (!string.IsNullOrWhiteSpace(request.SessionId))
+ claims.Add(new Claim(Model.Security.SessionClaimTypes.SessionId, request.SessionId));
+
+ var ecdsa = signingCertificate.GetECDsaPrivateKey();
+ if (ecdsa == null)
+ throw new InvalidOperationException("The grant signing certificate does not carry an ECDSA private key (ES256 is required).");
+
+ var credentials = new SigningCredentials(new ECDsaSecurityKey(ecdsa), SecurityAlgorithms.EcdsaSha256);
+ var token = new JwtSecurityToken(
+ issuer: Config.DataProtectionConfig.GrantIssuer,
+ audience: Config.DataProtectionConfig.GrantAudience,
+ claims: claims,
+ notBefore: now,
+ expires: expires,
+ signingCredentials: credentials);
+ token.Payload[JwtRegisteredClaimNames.Iat] = ToUnixSeconds(now);
+
+ return new ProtectedDataGrantIssueResult
+ {
+ GrantId = grantId,
+ Token = TokenHandler.WriteToken(token),
+ ExpiresOnUtc = expires
+ };
+ }
+
+ public ProtectedDataGrantValidationOutcome ValidateGrant(string token, int expectedDepartmentId,
+ long currentPolicyEpoch, string requiredScope, out ProtectedDataGrant grant, DateTime? utcNow = null)
+ {
+ grant = null;
+
+ var validationCertificate = GetValidationCertificate();
+ if (validationCertificate == null)
+ return ProtectedDataGrantValidationOutcome.NotConfigured;
+
+ if (string.IsNullOrWhiteSpace(token) || expectedDepartmentId <= 0)
+ return ProtectedDataGrantValidationOutcome.Invalid;
+
+ ClaimsPrincipal principal;
+ JwtSecurityToken parsedToken;
+ try
+ {
+ var ecdsa = validationCertificate.GetECDsaPublicKey();
+ if (ecdsa == null)
+ return ProtectedDataGrantValidationOutcome.NotConfigured;
+
+ // Lifetime is checked manually below against the caller-supplied clock (bounded
+ // skew, deterministic tests); everything cryptographic is checked here with the
+ // algorithm pinned to ES256 — "alg" in the token buys an attacker nothing.
+ var parameters = new TokenValidationParameters
+ {
+ ValidIssuer = Config.DataProtectionConfig.GrantIssuer,
+ ValidAudience = Config.DataProtectionConfig.GrantAudience,
+ IssuerSigningKey = new ECDsaSecurityKey(ecdsa),
+ ValidAlgorithms = new[] { SecurityAlgorithms.EcdsaSha256 },
+ ValidateIssuer = true,
+ ValidateAudience = true,
+ ValidateIssuerSigningKey = true,
+ ValidateLifetime = false,
+ RequireExpirationTime = true,
+ RequireSignedTokens = true
+ };
+
+ principal = TokenHandler.ValidateToken(token, parameters, out var validated);
+ parsedToken = (JwtSecurityToken)validated;
+ }
+ catch (Exception)
+ {
+ // Malformed, wrong algorithm, wrong issuer/audience, or bad signature — all one
+ // value-free outcome; the distinction never reaches a caller.
+ return ProtectedDataGrantValidationOutcome.Invalid;
+ }
+
+ var now = utcNow ?? DateTime.UtcNow;
+ var skew = TimeSpan.FromSeconds(Math.Max(0, Config.DataProtectionConfig.GrantClockSkewSeconds));
+
+ if (parsedToken.ValidTo == DateTime.MinValue || now > parsedToken.ValidTo.Add(skew))
+ return ProtectedDataGrantValidationOutcome.Expired;
+ if (parsedToken.ValidFrom != DateTime.MinValue && now < parsedToken.ValidFrom.Subtract(skew))
+ return ProtectedDataGrantValidationOutcome.Invalid;
+
+ if (!int.TryParse(principal.FindFirst(DepartmentClaim)?.Value, out var departmentId))
+ return ProtectedDataGrantValidationOutcome.Invalid;
+ if (departmentId != expectedDepartmentId)
+ return ProtectedDataGrantValidationOutcome.WrongDepartment;
+
+ if (!long.TryParse(principal.FindFirst(PolicyEpochClaim)?.Value, out var policyEpoch))
+ return ProtectedDataGrantValidationOutcome.Invalid;
+ // Exact match required: a bump revokes older grants, and a grant claiming a FUTURE epoch
+ // is equally untrustworthy — fail closed on any mismatch.
+ if (policyEpoch != currentPolicyEpoch)
+ return ProtectedDataGrantValidationOutcome.EpochRevoked;
+
+ var scopes = (principal.FindFirst(ScopeClaim)?.Value ?? string.Empty)
+ .Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (!string.IsNullOrWhiteSpace(requiredScope) && !scopes.Contains(requiredScope, StringComparer.Ordinal))
+ return ProtectedDataGrantValidationOutcome.MissingScope;
+
+ var userId = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value
+ ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
+ if (string.IsNullOrWhiteSpace(userId))
+ return ProtectedDataGrantValidationOutcome.Invalid;
+
+ int.TryParse(principal.FindFirst(ClientAppClaim)?.Value, out var clientApp);
+ long.TryParse(principal.FindFirst(MfaAtClaim)?.Value, out var mfaAtSeconds);
+
+ grant = new ProtectedDataGrant
+ {
+ GrantId = principal.FindFirst(JwtRegisteredClaimNames.Jti)?.Value,
+ UserId = userId,
+ DepartmentId = departmentId,
+ SessionId = principal.FindFirst(Model.Security.SessionClaimTypes.SessionId)?.Value,
+ ClientApp = clientApp,
+ PolicyEpoch = policyEpoch,
+ Scopes = scopes,
+ MfaAtUtc = DateTimeOffset.FromUnixTimeSeconds(mfaAtSeconds).UtcDateTime,
+ IssuedAtUtc = parsedToken.IssuedAt,
+ ExpiresOnUtc = parsedToken.ValidTo
+ };
+ return ProtectedDataGrantValidationOutcome.Valid;
+ }
+
+ private X509Certificate2 GetSigningCertificate() => _signingCertificate.Value;
+
+ private X509Certificate2 GetValidationCertificate() => _validationCertificate.Value;
+
+ private static X509Certificate2 LoadSigningCertificateSafe(Func loader)
+ {
+ try
+ {
+ var certificate = loader();
+ if (certificate != null && certificate.GetECDsaPrivateKey() == null)
+ {
+ Logging.LogError("Protected Data Grant signing certificate has no ECDSA private key; grant issuance is disabled on this host.");
+ return null;
+ }
+
+ return certificate;
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, "Protected Data Grant signing certificate failed to load; grant issuance is disabled on this host.");
+ return null;
+ }
+ }
+
+ private static X509Certificate2 LoadValidationCertificateSafe(Func loader)
+ {
+ try
+ {
+ return loader();
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, "Protected Data Grant validation certificate failed to load; grant validation is disabled on this host.");
+ return null;
+ }
+ }
+
+ private static X509Certificate2 LoadSigningCertificateFromConfig()
+ {
+ var path = Config.DataProtectionConfig.GrantSigningCertificatePath;
+ if (string.IsNullOrWhiteSpace(path))
+ return null;
+
+ return X509CertificateLoader.LoadPkcs12FromFile(path,
+ Config.DataProtectionConfig.GrantSigningCertificatePassword);
+ }
+
+ private static X509Certificate2 LoadValidationCertificateFromConfig()
+ {
+ var path = Config.DataProtectionConfig.GrantValidationCertificatePath;
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ // Single-host development fallback: validate with the signing certificate's public part.
+ return LoadSigningCertificateFromConfig();
+ }
+
+ try
+ {
+ return X509CertificateLoader.LoadCertificateFromFile(path);
+ }
+ catch (CryptographicException)
+ {
+ // Not a DER/PEM certificate — allow a PFX that carries only the public chain too.
+ return X509CertificateLoader.LoadPkcs12FromFile(path, string.Empty);
+ }
+ }
+
+ private static long ToUnixSeconds(DateTime utc) =>
+ new DateTimeOffset(DateTime.SpecifyKind(utc, DateTimeKind.Utc)).ToUnixTimeSeconds();
+ }
+}
diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs
new file mode 100644
index 000000000..7bb422a53
--- /dev/null
+++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs
@@ -0,0 +1,152 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Catalog v1 (draft until the Phase 0 catalog freeze): the P0 families from ADP plan section 5.1
+ /// — calls, call children, department-scoped personnel data, and contacts. FieldIds are stable
+ /// forever (they are AAD components); entries are only ever ADDED, with the catalog version
+ /// incremented. Section 5.2/5.3 operational, moderation, and section 22.1 audit families land in
+ /// later versions before the freeze. Linked Address rows are deliberately absent until the
+ /// shared-Address ownership migration exists (section 5.1).
+ ///
+ public class ProtectedFieldCatalog : IProtectedFieldCatalog
+ {
+ private const string CallsFamily = "Calls";
+ private const string PersonnelFamily = "Personnel";
+ private const string ContactsFamily = "Contacts";
+
+ private static readonly IReadOnlyList Entries = BuildV1();
+ private static readonly Dictionary ById =
+ Entries.ToDictionary(e => e.FieldId, StringComparer.OrdinalIgnoreCase);
+ private static readonly Dictionary> ByTable =
+ Entries.GroupBy(e => e.TableName, StringComparer.OrdinalIgnoreCase)
+ .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList(), StringComparer.OrdinalIgnoreCase);
+
+ public int Version => 1;
+
+ public IReadOnlyList GetAll() => Entries;
+
+ public IReadOnlyList GetForTable(string tableName)
+ {
+ if (string.IsNullOrWhiteSpace(tableName))
+ return Array.Empty();
+
+ return ByTable.TryGetValue(tableName, out var entries) ? entries : Array.Empty();
+ }
+
+ public ProtectedFieldDefinition GetById(string fieldId)
+ {
+ if (string.IsNullOrWhiteSpace(fieldId))
+ return null;
+
+ return ById.TryGetValue(fieldId, out var entry) ? entry : null;
+ }
+
+ public bool IsProtectedField(string tableName, string columnName)
+ {
+ if (string.IsNullOrWhiteSpace(columnName))
+ return false;
+
+ return GetForTable(tableName).Any(e => string.Equals(e.ColumnName, columnName, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static IReadOnlyList BuildV1()
+ {
+ var list = new List();
+
+ // ---- Calls (section 5.1). User-authored "number/type/name" values are protected even
+ // though their labels look structural; the system-generated Calls.Number stays plaintext.
+ void Call(string column, ProtectedFieldClassification classification, ProtectedFieldStorageKind kind = ProtectedFieldStorageKind.Text) =>
+ list.Add(new ProtectedFieldDefinition($"calls.{column.ToLowerInvariant()}", CallsFamily, "Calls", column,
+ kind, classification, PermissionTypes.ViewProtectedCallData, PermissionTypes.EditProtectedCallData));
+
+ Call("Name", ProtectedFieldClassification.Sensitive);
+ Call("Type", ProtectedFieldClassification.Sensitive);
+ Call("NatureOfCall", ProtectedFieldClassification.Phi);
+ Call("Notes", ProtectedFieldClassification.Phi);
+ Call("CompletedNotes", ProtectedFieldClassification.Phi);
+ Call("Address", ProtectedFieldClassification.Pii);
+ Call("GeoLocationData", ProtectedFieldClassification.Pii);
+ Call("W3W", ProtectedFieldClassification.Pii);
+ Call("ContactName", ProtectedFieldClassification.Pii);
+ Call("ContactNumber", ProtectedFieldClassification.Pii);
+ Call("SourceIdentifier", ProtectedFieldClassification.Sensitive);
+ Call("IncidentNumber", ProtectedFieldClassification.Sensitive);
+ Call("ExternalIdentifier", ProtectedFieldClassification.Sensitive);
+ Call("ReferenceNumber", ProtectedFieldClassification.Sensitive);
+ Call("CallFormData", ProtectedFieldClassification.Phi);
+ Call("DeletedReason", ProtectedFieldClassification.Sensitive);
+
+ // ---- Call children.
+ void CallChild(string table, string column, ProtectedFieldClassification classification,
+ ProtectedFieldStorageKind kind = ProtectedFieldStorageKind.Text) =>
+ list.Add(new ProtectedFieldDefinition($"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", CallsFamily, table, column,
+ kind, classification, PermissionTypes.ViewProtectedCallData, PermissionTypes.EditProtectedCallData));
+
+ CallChild("CallNotes", "Note", ProtectedFieldClassification.Phi);
+ CallChild("CallNotes", "FlaggedReason", ProtectedFieldClassification.Sensitive);
+ CallChild("CallNotes", "Latitude", ProtectedFieldClassification.Pii, ProtectedFieldStorageKind.CompanionColumn);
+ CallChild("CallNotes", "Longitude", ProtectedFieldClassification.Pii, ProtectedFieldStorageKind.CompanionColumn);
+ CallChild("CallLogs", "Narrative", ProtectedFieldClassification.Phi);
+ CallChild("CallReferences", "Note", ProtectedFieldClassification.Phi);
+ CallChild("CallAttachments", "Name", ProtectedFieldClassification.Sensitive);
+ CallChild("CallAttachments", "FileName", ProtectedFieldClassification.Sensitive);
+ CallChild("CallAttachments", "FlaggedReason", ProtectedFieldClassification.Sensitive);
+ CallChild("CallAttachments", "Data", ProtectedFieldClassification.Phi, ProtectedFieldStorageKind.Binary);
+ CallChild("CallAttachments", "Latitude", ProtectedFieldClassification.Pii, ProtectedFieldStorageKind.CompanionColumn);
+ CallChild("CallAttachments", "Longitude", ProtectedFieldClassification.Pii, ProtectedFieldStorageKind.CompanionColumn);
+
+ // ---- Personnel: department-scoped sensitive attributes live on DepartmentMemberSensitiveData
+ // (never the global UserProfile row — a user can belong to several departments).
+ void Member(string column, ProtectedFieldClassification classification) =>
+ list.Add(new ProtectedFieldDefinition($"departmentmembersensitivedata.{column.ToLowerInvariant()}", PersonnelFamily,
+ "DepartmentMemberSensitiveData", column, ProtectedFieldStorageKind.Text, classification,
+ PermissionTypes.ViewProtectedPersonnelData));
+
+ Member("IdentificationNumber", ProtectedFieldClassification.Pii);
+ Member("EmergencyContactName", ProtectedFieldClassification.Pii);
+ Member("EmergencyContactPhone", ProtectedFieldClassification.Pii);
+ Member("Notes", ProtectedFieldClassification.Sensitive);
+
+ // ---- Contacts (section 5.1: all name parts, email, government IDs, phone fields,
+ // description/other information, image, GPS/geofence, and ContactNote.Note).
+ void Contact(string column, ProtectedFieldClassification classification,
+ ProtectedFieldStorageKind kind = ProtectedFieldStorageKind.Text) =>
+ list.Add(new ProtectedFieldDefinition($"contacts.{column.ToLowerInvariant()}", ContactsFamily, "Contacts", column,
+ kind, classification, PermissionTypes.ViewProtectedContactData));
+
+ Contact("FirstName", ProtectedFieldClassification.Pii);
+ Contact("MiddleName", ProtectedFieldClassification.Pii);
+ Contact("LastName", ProtectedFieldClassification.Pii);
+ Contact("OtherName", ProtectedFieldClassification.Pii);
+ Contact("CompanyName", ProtectedFieldClassification.Pii);
+ Contact("Email", ProtectedFieldClassification.Pii);
+ Contact("CountryIssuedIdNumber", ProtectedFieldClassification.Pii);
+ Contact("CountryIdName", ProtectedFieldClassification.Pii);
+ Contact("StateIdNumber", ProtectedFieldClassification.Pii);
+ Contact("StateIdName", ProtectedFieldClassification.Pii);
+ Contact("StateIdCountryName", ProtectedFieldClassification.Pii);
+ Contact("HomePhoneNumber", ProtectedFieldClassification.Pii);
+ Contact("CellPhoneNumber", ProtectedFieldClassification.Pii);
+ Contact("FaxPhoneNumber", ProtectedFieldClassification.Pii);
+ Contact("OfficePhoneNumber", ProtectedFieldClassification.Pii);
+ Contact("Description", ProtectedFieldClassification.Sensitive);
+ Contact("OtherInfo", ProtectedFieldClassification.Sensitive);
+ Contact("Image", ProtectedFieldClassification.Pii, ProtectedFieldStorageKind.Binary);
+ Contact("LocationGpsCoordinates", ProtectedFieldClassification.Pii);
+ Contact("EntranceGpsCoordinates", ProtectedFieldClassification.Pii);
+ Contact("ExitGpsCoordinates", ProtectedFieldClassification.Pii);
+ Contact("LocationGeofence", ProtectedFieldClassification.Pii);
+
+ list.Add(new ProtectedFieldDefinition("contactnotes.note", ContactsFamily, "ContactNotes", "Note",
+ ProtectedFieldStorageKind.Text, ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedContactData));
+
+ return list;
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ProtectedFieldCryptoService.cs b/Core/Resgrid.Services/ProtectedFieldCryptoService.cs
new file mode 100644
index 000000000..f609dfbad
--- /dev/null
+++ b/Core/Resgrid.Services/ProtectedFieldCryptoService.cs
@@ -0,0 +1,228 @@
+using System;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// AES-256-GCM field cryptography for ADP envelopes. See
+ /// for the contract. Text envelopes are
+ /// rgdp:1:{keyVersion}:{base64(nonce|tag|ciphertext)}; binary blobs are
+ /// "rgdpb:1:{keyVersion}:" ASCII header bytes followed by raw nonce|tag|ciphertext. The
+ /// department key version rides in the envelope header (plaintext, needed to resolve the DEK) and
+ /// is deliberately NOT part of the AAD — rotation rewraps DEKs without re-encrypting fields.
+ ///
+ public class ProtectedFieldCryptoService : IProtectedFieldCryptoService
+ {
+ private const int NonceSize = 12;
+ private const int TagSize = 16;
+
+ public string EncryptText(byte[] dek, int departmentKeyVersion, string plaintext,
+ int departmentId, string catalogFieldId, string rowKey, int catalogVersion)
+ {
+ if (plaintext == null)
+ throw new ArgumentNullException(nameof(plaintext));
+ if (ProtectedDataEnvelope.HasEnvelopePrefix(plaintext))
+ throw new InvalidOperationException(
+ "Refusing to encrypt a value that already carries an ADP envelope prefix; the double-encryption guard must run before field crypto.");
+
+ var plainBytes = Encoding.UTF8.GetBytes(plaintext);
+ try
+ {
+ var payload = Seal(dek, plainBytes,
+ Aad(departmentId, catalogFieldId, rowKey, ProtectedDataEnvelope.CurrentVersion, catalogVersion));
+ return ProtectedDataEnvelope.Format(departmentKeyVersion, Convert.ToBase64String(payload));
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(plainBytes);
+ }
+ }
+
+ public string DecryptText(byte[] dek, string envelope,
+ int departmentId, string catalogFieldId, string rowKey, int catalogVersion)
+ {
+ if (!ProtectedDataEnvelope.TryParse(envelope, out var formatVersion, out _, out var payloadBase64))
+ throw new CryptographicException("Value is not a parseable ADP envelope of a supported version.");
+
+ var payload = Convert.FromBase64String(payloadBase64);
+ // AAD binds the format version the envelope was WRITTEN with — a later CurrentVersion
+ // bump must not make existing envelopes fail authentication.
+ var plainBytes = Open(dek, payload, Aad(departmentId, catalogFieldId, rowKey, formatVersion, catalogVersion));
+ try
+ {
+ return Encoding.UTF8.GetString(plainBytes);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(plainBytes);
+ }
+ }
+
+ public byte[] EncryptBinary(byte[] dek, int departmentKeyVersion, byte[] plaintext,
+ int departmentId, string catalogFieldId, string rowKey, int catalogVersion)
+ {
+ if (plaintext == null)
+ throw new ArgumentNullException(nameof(plaintext));
+ // Same invariant ProtectedDataEnvelope.Format enforces on the text path: a non-positive
+ // key version would produce a blob TryParseBinaryHeader (and so DecryptBinary) always
+ // rejects — an undecryptable write must fail at write time.
+ if (departmentKeyVersion <= 0)
+ throw new ArgumentOutOfRangeException(nameof(departmentKeyVersion));
+ if (IsBinaryEnveloped(plaintext))
+ throw new InvalidOperationException(
+ "Refusing to encrypt a blob that already carries the rgdpb envelope header; the double-encryption guard must run before field crypto.");
+
+ var header = Encoding.ASCII.GetBytes($"{ProtectedDataEnvelope.BinaryPrefix}{ProtectedDataEnvelope.CurrentVersion}:{departmentKeyVersion}:");
+ var payload = Seal(dek, plaintext,
+ Aad(departmentId, catalogFieldId, rowKey, ProtectedDataEnvelope.CurrentVersion, catalogVersion));
+
+ var result = new byte[header.Length + payload.Length];
+ Buffer.BlockCopy(header, 0, result, 0, header.Length);
+ Buffer.BlockCopy(payload, 0, result, header.Length, payload.Length);
+ return result;
+ }
+
+ public byte[] DecryptBinary(byte[] dek, byte[] envelope,
+ int departmentId, string catalogFieldId, string rowKey, int catalogVersion)
+ {
+ if (!TryParseBinaryHeader(envelope, out var payloadOffset, out var formatVersion))
+ throw new CryptographicException("Blob is not a parseable rgdpb envelope of a supported version.");
+
+ var payload = new byte[envelope.Length - payloadOffset];
+ Buffer.BlockCopy(envelope, payloadOffset, payload, 0, payload.Length);
+ // AAD binds the format version the envelope was WRITTEN with (see DecryptText).
+ return Open(dek, payload, Aad(departmentId, catalogFieldId, rowKey, formatVersion, catalogVersion));
+ }
+
+ public bool TryGetBinaryEnvelopeKeyVersion(byte[] value, out int departmentKeyVersion)
+ {
+ departmentKeyVersion = 0;
+ if (!TryParseBinaryHeader(value, out var payloadOffset, out _))
+ return false;
+
+ var headerText = Encoding.ASCII.GetString(value, 0, payloadOffset);
+ var parts = headerText.Split(':');
+ return int.TryParse(parts[2], out departmentKeyVersion) && departmentKeyVersion > 0;
+ }
+
+ public bool IsBinaryEnveloped(byte[] value)
+ {
+ if (value == null || value.Length < ProtectedDataEnvelope.BinaryPrefix.Length)
+ return false;
+
+ for (var i = 0; i < ProtectedDataEnvelope.BinaryPrefix.Length; i++)
+ {
+ if (value[i] != (byte)ProtectedDataEnvelope.BinaryPrefix[i])
+ return false;
+ }
+
+ return true;
+ }
+
+ ///
+ /// AAD binding per plan section 4.1: department, stable catalog field id, stable per-row key,
+ /// and envelope+catalog versions. The envelope format version is the one carried by the
+ /// envelope being read (or CurrentVersion when writing) — never blindly CurrentVersion, or a
+ /// format bump would make every existing envelope fail authentication. The pipe separator is
+ /// safe because every component is either numeric or a catalog/PK identifier that cannot
+ /// contain '|'.
+ ///
+ private static byte[] Aad(int departmentId, string catalogFieldId, string rowKey, int envelopeFormatVersion, int catalogVersion)
+ {
+ if (string.IsNullOrWhiteSpace(catalogFieldId))
+ throw new ArgumentException("Catalog field id is required for AAD binding.", nameof(catalogFieldId));
+ if (string.IsNullOrWhiteSpace(rowKey))
+ throw new ArgumentException("Row key is required for AAD binding.", nameof(rowKey));
+
+ return Encoding.UTF8.GetBytes(string.Create(CultureInfo.InvariantCulture,
+ $"rgdp|{departmentId}|{catalogFieldId}|{rowKey}|{envelopeFormatVersion}|{catalogVersion}"));
+ }
+
+ private static byte[] Seal(byte[] dek, byte[] plaintext, byte[] aad)
+ {
+ if (dek == null || dek.Length != 32)
+ throw new ArgumentException("A 256-bit DEK is required.", nameof(dek));
+
+ var nonce = new byte[NonceSize];
+ RandomNumberGenerator.Fill(nonce);
+ var tag = new byte[TagSize];
+ var ciphertext = new byte[plaintext.Length];
+
+ using (var aes = new AesGcm(dek, TagSize))
+ aes.Encrypt(nonce, plaintext, ciphertext, tag, aad);
+
+ var payload = new byte[NonceSize + TagSize + ciphertext.Length];
+ Buffer.BlockCopy(nonce, 0, payload, 0, NonceSize);
+ Buffer.BlockCopy(tag, 0, payload, NonceSize, TagSize);
+ Buffer.BlockCopy(ciphertext, 0, payload, NonceSize + TagSize, ciphertext.Length);
+ return payload;
+ }
+
+ private static byte[] Open(byte[] dek, byte[] payload, byte[] aad)
+ {
+ if (dek == null || dek.Length != 32)
+ throw new ArgumentException("A 256-bit DEK is required.", nameof(dek));
+ if (payload == null || payload.Length < NonceSize + TagSize)
+ throw new CryptographicException("Envelope payload is truncated.");
+
+ var nonce = new byte[NonceSize];
+ var tag = new byte[TagSize];
+ var ciphertext = new byte[payload.Length - NonceSize - TagSize];
+ Buffer.BlockCopy(payload, 0, nonce, 0, NonceSize);
+ Buffer.BlockCopy(payload, NonceSize, tag, 0, TagSize);
+ Buffer.BlockCopy(payload, NonceSize + TagSize, ciphertext, 0, ciphertext.Length);
+
+ var plaintext = new byte[ciphertext.Length];
+ using (var aes = new AesGcm(dek, TagSize))
+ aes.Decrypt(nonce, ciphertext, tag, plaintext, aad);
+
+ return plaintext;
+ }
+
+ /// Parses "rgdpb:{format}:{keyVersion}:" and returns the payload offset and format version.
+ private static bool TryParseBinaryHeader(byte[] value, out int payloadOffset, out int formatVersion)
+ {
+ payloadOffset = 0;
+ formatVersion = 0;
+ if (value == null || value.Length < ProtectedDataEnvelope.BinaryPrefix.Length + 4)
+ return false;
+
+ for (var i = 0; i < ProtectedDataEnvelope.BinaryPrefix.Length; i++)
+ {
+ if (value[i] != (byte)ProtectedDataEnvelope.BinaryPrefix[i])
+ return false;
+ }
+
+ // Header is ASCII digits and ':' only; scan for the third ':' overall after the prefix.
+ var colonsSeen = 0;
+ for (var i = ProtectedDataEnvelope.BinaryPrefix.Length; i < Math.Min(value.Length, 40); i++)
+ {
+ var b = value[i];
+ if (b == (byte)':')
+ {
+ colonsSeen++;
+ if (colonsSeen == 2)
+ {
+ payloadOffset = i + 1;
+ var headerText = Encoding.ASCII.GetString(value, 0, i + 1);
+ var parts = headerText.Split(':');
+ return parts.Length == 4 &&
+ int.TryParse(parts[1], out formatVersion) && formatVersion > 0 &&
+ formatVersion <= ProtectedDataEnvelope.CurrentVersion &&
+ int.TryParse(parts[2], out var keyVersion) && keyVersion > 0;
+ }
+ }
+ else if (b < (byte)'0' || b > (byte)'9')
+ {
+ return false;
+ }
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/ProtectedProjectionService.cs b/Core/Resgrid.Services/ProtectedProjectionService.cs
new file mode 100644
index 000000000..afe325f5e
--- /dev/null
+++ b/Core/Resgrid.Services/ProtectedProjectionService.cs
@@ -0,0 +1,228 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ ///
+ /// Safe projections for unattended consumers. See for
+ /// the contract. Redaction is name-based against the protected-field catalog's column names:
+ /// deliberately over-broad (a property named like any cataloged column is redacted wherever it
+ /// appears in the event graph) because over-redaction is a cosmetic defect while under-redaction
+ /// is a disclosure.
+ ///
+ public class ProtectedProjectionService : IProtectedProjectionService
+ {
+ private readonly IDepartmentDataProtectionService _dataProtectionService;
+ private readonly IProtectedFieldCatalog _catalog;
+
+ private readonly Lazy<(HashSet Scalar, HashSet Binary)> _protectedNames;
+
+ public ProtectedProjectionService(IDepartmentDataProtectionService dataProtectionService,
+ IProtectedFieldCatalog catalog)
+ {
+ _dataProtectionService = dataProtectionService;
+ _catalog = catalog;
+ _protectedNames = new Lazy<(HashSet, HashSet)>(() =>
+ {
+ var scalar = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var binary = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var entry in _catalog.GetAll())
+ {
+ if (entry.StorageKind == ProtectedFieldStorageKind.Binary)
+ binary.Add(entry.ColumnName);
+ else
+ scalar.Add(entry.ColumnName);
+ }
+
+ return (scalar, binary);
+ });
+ }
+
+ public async Task BuildSafeWorkflowPayloadAsync(int departmentId, object eventPayload)
+ {
+ if (eventPayload == null)
+ return null;
+
+ bool enforced;
+ try
+ {
+ enforced = await _dataProtectionService.IsProtectionEnforcedAsync(departmentId);
+ }
+ catch (Exception ex)
+ {
+ // Unknown protection state must not leak plaintext: treat as enforced and redact.
+ Logging.LogException(ex, $"Protection-state lookup failed for department {departmentId}; redacting workflow payload defensively.");
+ enforced = true;
+ }
+
+ if (!enforced)
+ return JsonConvert.SerializeObject(eventPayload);
+
+ try
+ {
+ var root = JToken.FromObject(eventPayload);
+ var redactedFields = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ Redact(root, redactedFields);
+
+ if (root is JObject rootObject)
+ {
+ rootObject["is_redacted"] = true;
+ rootObject["redacted_fields"] = new JArray(redactedFields.Cast