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 Login Controls 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 Login Controls 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-resx 2.0 @@ -378,6 +378,26 @@ Ελέγχει ποιος μπορεί να συνδέεται στην εφαρμογή Dispatch. Το Dispatch εμφανίζει ιδιωτικές επικοινωνίες διοίκησης, μονάδων και ανταποκριτών για κάθε περιστατικό, οπότε περιορίστε το αν τα μέλη σας δεν είναι όλα διαβιβαστές. Σύνδεση στην Εφαρμογή Command Ελέγχει ποιος μπορεί να ενεργεί ως διοικητής: να συνδέεται στην εφαρμογή IC, να εγκαθιστά διοίκηση περιστατικού σε μια κλήση και να βλέπει πίνακες διοίκησης. Ο περιορισμός αυτού πέρα από το «Όλοι» επιτρέπει επίσης στα άτομα που επιλέγετε να βοηθούν στη λειτουργία οποιουδήποτε πίνακα διοίκησης (ανάθεση και μετακίνηση πόρων, εκτέλεση χρονομέτρων και λογοδοσίας) χωρίς να κατέχουν θέση ICS σε αυτόν &#8212; χρήσιμο για να βοηθούν οι διαβιβαστές στην εφαρμογή 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 Login Controls 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 Login Controls 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 Login Controls 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 Login Controls 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 Login Controls 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 Login Controls 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 Login Controls 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().ToArray()); + rootObject["catalog_version"] = _catalog.Version; + } + + return root.ToString(Formatting.None); + } + catch (Exception ex) + { + // A redaction fault NEVER falls back to plaintext — degrade to a minimal safe payload. + Logging.LogException(ex, $"Workflow payload redaction failed for department {departmentId}; emitting the minimal safe payload."); + return new JObject + { + ["is_redacted"] = true, + ["redaction_error"] = true, + ["catalog_version"] = _catalog.Version + }.ToString(Formatting.None); + } + } + + /// + /// The exact generic line the plan mandates for GenericOnly egress (section 9.1). + /// + public const string GenericDispatchText = "A protected dispatch is available. Sign in to Resgrid to view details."; + + public async Task BuildNotificationSafeCallAsync(int departmentId, Call call, ProtectedDataEgressChannel channel) + { + if (call == null) + return null; + + bool enforced; + try + { + enforced = await _dataProtectionService.IsProtectionEnforcedAsync(departmentId); + } + catch (Exception ex) + { + // Unknown protection state must not leak plaintext to a carrier or provider. + Logging.LogException(ex, $"Protection-state lookup failed for department {departmentId}; sanitizing the {channel} notification defensively."); + enforced = true; + } + + if (!enforced) + return call; + + if (await ChannelAllowsProtectedContentAsync(departmentId, channel)) + return call; + + // Sanitized clone: only the allowlisted system-generated call number, priority/color, + // and routing/structural fields survive (plan section 9.1). Every cataloged user-authored + // field is absent, so any downstream template, provider DTO, or TTS prompt built from + // this clone is value-free by construction. + return new Call + { + CallId = call.CallId, + DepartmentId = call.DepartmentId, + Department = call.Department, + Number = call.Number, + Priority = call.Priority, + CallPriority = call.CallPriority, + State = call.State, + IsCritical = call.IsCritical, + LoggedOn = call.LoggedOn, + Name = string.IsNullOrWhiteSpace(call.Number) ? "Protected dispatch" : call.Number, + NatureOfCall = GenericDispatchText + }; + } + + public async Task IsChannelSanitizedAsync(int departmentId, ProtectedDataEgressChannel channel) + { + bool enforced; + try + { + enforced = await _dataProtectionService.IsProtectionEnforcedAsync(departmentId); + } + catch (Exception ex) + { + // Unknown protection state must not leak plaintext to a carrier or provider. + Logging.LogException(ex, $"Protection-state lookup failed for department {departmentId}; treating the {channel} channel as sanitized defensively."); + return true; + } + + if (!enforced) + return false; + + return !await ChannelAllowsProtectedContentAsync(departmentId, channel); + } + + private async Task ChannelAllowsProtectedContentAsync(int departmentId, ProtectedDataEgressChannel channel) + { + // Third-party chat platforms have no policy column and are always generic when enforced. + if (channel == ProtectedDataEgressChannel.ChatPlatform) + return false; + + try + { + var egress = await _dataProtectionService.GetEgressPolicyByDepartmentIdAsync(departmentId); + var mode = channel switch + { + ProtectedDataEgressChannel.Push => egress.PushMode, + ProtectedDataEgressChannel.Sms => egress.SmsMode, + ProtectedDataEgressChannel.Email => egress.EmailMode, + ProtectedDataEgressChannel.Voice => egress.VoiceMode, + _ => (int)ProtectedDataEgressMode.GenericOnly + }; + + // ProtectedAfterPin degrades to GenericOnly until the PIN-release flow ships. + return mode == (int)ProtectedDataEgressMode.AllowProtectedContent; + } + catch (Exception ex) + { + Logging.LogException(ex, $"Egress-policy lookup failed for department {departmentId}; treating {channel} as GenericOnly."); + return false; + } + } + + private void Redact(JToken token, ISet redactedFields) + { + switch (token) + { + case JObject obj: + // Materialize first: binary properties are removed while iterating. + foreach (var property in obj.Properties().ToList()) + { + if (_protectedNames.Value.Binary.Contains(property.Name)) + { + // Binaries are omitted from safe projections, never inlined or replaced. + redactedFields.Add(property.Name); + property.Remove(); + continue; + } + + if (_protectedNames.Value.Scalar.Contains(property.Name) && + property.Value is JValue { Type: not JTokenType.Null }) + { + property.Value = ProtectedDataEnvelope.RedactionValue; + redactedFields.Add(property.Name); + continue; + } + + Redact(property.Value, redactedFields); + } + + break; + + case JArray array: + foreach (var item in array) + Redact(item, redactedFields); + break; + } + } + } +} diff --git a/Core/Resgrid.Services/Resgrid.Services.csproj b/Core/Resgrid.Services/Resgrid.Services.csproj index 3a45ef479..ffaa9dd97 100644 --- a/Core/Resgrid.Services/Resgrid.Services.csproj +++ b/Core/Resgrid.Services/Resgrid.Services.csproj @@ -16,6 +16,7 @@ + diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index e9df11c56..64ef1f767 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -1,5 +1,6 @@ using System; using Autofac; +using Resgrid.Model.Providers; using Resgrid.Model.Services; using Resgrid.Services.CallEmailTemplates; using RestSharp; @@ -162,6 +163,34 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Advanced Data Protection (ADP) + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().SingleInstance(); + + // The real engine is registered everywhere but only functions where a real key wrapping + // provider resolves (LocalDev for synthetic testing; the broker host in production). On + // the app tier the NotConfigured provider makes every run fail closed with + // kms_unavailable, which the coordinator treats like any other unrecoverable error. + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + + // Key wrapping: only the LocalDev provider (synthetic/non-PHI testing; refuses to run in + // production) is resolvable in-process. Any other configured provider registers the + // fail-closed placeholder — real KMS adapters live with the Protected Data Broker, which + // Web/API/worker hosts deliberately cannot reach (ADP plan section 2.2). + if (string.Equals(Config.DataProtectionConfig.KeyWrappingProviderType, "LocalDev", StringComparison.OrdinalIgnoreCase)) + builder.RegisterType().As().SingleInstance(); + else + // PreserveExistingDefaults: if a composition root loaded a REAL adapter module before + // this one (module order is host code, not a guarantee), the fail-closed placeholder + // must not silently win the last-registration race and break the broker. + builder.RegisterType().As().SingleInstance().PreserveExistingDefaults(); + //builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); diff --git a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs index e7cbf6ed3..707fd101c 100644 --- a/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs +++ b/Providers/Resgrid.Providers.Bus/WorkflowEventProvider.cs @@ -25,6 +25,7 @@ public class WorkflowEventProvider : IWorkflowEventProvider private static IWorkflowRunRepository _runRepository; private static IDepartmentsService _departmentsService; private static ISubscriptionsService _subscriptionsService; + private static IProtectedProjectionService _protectedProjectionService; // Per-minute rate limit tracker: departmentId → (window start, count) private static readonly System.Collections.Concurrent.ConcurrentDictionary _rateLimitTracker @@ -52,7 +53,8 @@ public WorkflowEventProvider( IWorkflowRepository workflowRepository, IWorkflowRunRepository runRepository, IDepartmentsService departmentsService, - ISubscriptionsService subscriptionsService) + ISubscriptionsService subscriptionsService, + IProtectedProjectionService protectedProjectionService) { _eventAggregator = eventAggregator; _outboundQueueProvider = outboundQueueProvider; @@ -60,6 +62,7 @@ public WorkflowEventProvider( _runRepository = runRepository; _departmentsService = departmentsService; _subscriptionsService = subscriptionsService; + _protectedProjectionService = protectedProjectionService; RegisterListeners(); } @@ -156,7 +159,10 @@ private static async void HandleEvent(int departmentId, WorkflowTriggerEventType if (workflows == null) return; - var payloadJson = JsonConvert.SerializeObject(eventObj); + // ADP safe projection (plan section 8): for protected departments the payload is + // redacted HERE, before it reaches WorkflowRun.InputPayload, the queue, retries, + // dead letters, history, or designer previews. + var payloadJson = await _protectedProjectionService.BuildSafeWorkflowPayloadAsync(departmentId, eventObj); var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); var deptCode = department?.Code ?? string.Empty; diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0124_AddDepartmentDataProtection.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0124_AddDepartmentDataProtection.cs new file mode 100644 index 000000000..708e49ad7 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0124_AddDepartmentDataProtection.cs @@ -0,0 +1,161 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Advanced Data Protection (ADP) Phase 1 schemas: durable per-department protection policy + /// (DepartmentDataProtectionPolicies.State is the single data-safety truth), wrapped department + /// key versions (never plaintext key material), resumable bulk-migration cursors, independent + /// per-channel egress policy, and department-owned member sensitive data moved off the global + /// UserProfile row. All tables ship inert while every department is Disabled. + /// Runs outside a migration transaction so ONLINE index builds do not hold schema locks until + /// commit; every statement is existence-guarded for safe retry. + /// + [Migration(124, TransactionBehavior.None)] + public class M0124_AddDepartmentDataProtection : Migration + { + public override void Up() + { + if (!Schema.Table("DepartmentDataProtectionPolicies").Exists()) + Create.Table("DepartmentDataProtectionPolicies") + .WithColumn("DepartmentDataProtectionPolicyId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("State").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CatalogVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("ActiveMigrationKind").AsInt32().Nullable() + .WithColumn("StepUpWindowMinutes").AsInt32().NotNullable().WithDefaultValue(15) + .WithColumn("StepUpWindowReason").AsString(int.MaxValue).Nullable() + .WithColumn("PolicyEpoch").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("MinimumClientVersionsJson").AsString(int.MaxValue).Nullable() + .WithColumn("AcknowledgementsJson").AsString(int.MaxValue).Nullable() + .WithColumn("AcknowledgedByUserId").AsString(128).Nullable() + .WithColumn("AcknowledgedOn").AsDateTime2().Nullable() + .WithColumn("EnrollmentFlagEvaluationJson").AsString(int.MaxValue).Nullable() + .WithColumn("AddonBillingReference").AsString(256).Nullable() + .WithColumn("MigrationWindowStartLocal").AsString(5).Nullable() + .WithColumn("MigrationWindowEndLocal").AsString(5).Nullable() + .WithColumn("MigrationWindowTimeZone").AsString(128).Nullable() + .WithColumn("OffboardingEffectiveOn").AsDateTime2().Nullable() + .WithColumn("OffboardingSource").AsInt32().Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("UpdatedOn").AsDateTime2().Nullable() + .WithColumn("UpdatedByUserId").AsString(128).Nullable(); + + // One policy row per department. + Execute.Sql(SqlServerOnlineIndex.Create("UX_DepartmentDataProtectionPolicies_DepartmentId", + "DepartmentDataProtectionPolicies", new[] { "[DepartmentId] ASC" }, unique: true)); + + if (!Schema.Table("DepartmentDataProtectionKeys").Exists()) + Create.Table("DepartmentDataProtectionKeys") + .WithColumn("DepartmentDataProtectionKeyId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Version").AsInt32().NotNullable() + .WithColumn("WrappedKey").AsString(int.MaxValue).NotNullable() + .WithColumn("ProviderType").AsString(64).NotNullable() + .WithColumn("ProviderKeyReference").AsString(256).NotNullable() + .WithColumn("ProviderKeyVersion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("ActivatedOn").AsDateTime2().Nullable() + .WithColumn("RetiredOn").AsDateTime2().Nullable(); + + // Envelope headers reference (DepartmentId, Version); that pair must be unique. + Execute.Sql(SqlServerOnlineIndex.Create("UX_DepartmentDataProtectionKeys_Department_Version", + "DepartmentDataProtectionKeys", new[] { "[DepartmentId] ASC", "[Version] ASC" }, unique: true)); + + Execute.Sql(SqlServerOnlineIndex.Create("IX_DepartmentDataProtectionKeys_Department_Status", + "DepartmentDataProtectionKeys", new[] { "[DepartmentId] ASC", "[Status] ASC" })); + + if (!Schema.Table("DepartmentDataProtectionMigrations").Exists()) + Create.Table("DepartmentDataProtectionMigrations") + .WithColumn("DepartmentDataProtectionMigrationId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("Kind").AsInt32().NotNullable() + .WithColumn("CatalogVersion").AsInt32().NotNullable() + .WithColumn("TargetKeyVersion").AsInt32().Nullable() + .WithColumn("TargetTable").AsString(128).NotNullable() + .WithColumn("Cursor").AsString(256).Nullable() + .WithColumn("RowsTotal").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("RowsProcessed").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("RowsAlreadyProtected").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("RowsAnomalous").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("VerificationState").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("Attempts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("LastErrorCode").AsString(64).Nullable() + .WithColumn("CorrelationId").AsString(128).Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("StartedOn").AsDateTime2().Nullable() + .WithColumn("CheckpointedOn").AsDateTime2().Nullable() + .WithColumn("CompletedOn").AsDateTime2().Nullable(); + + // One active cursor row per table per run kind; completed history rows are unconstrained. + Execute.Sql(SqlServerOnlineIndex.Create("UX_DepartmentDataProtectionMigrations_Active", + "DepartmentDataProtectionMigrations", new[] { "[DepartmentId] ASC", "[Kind] ASC", "[TargetTable] ASC" }, + unique: true, filter: "[CompletedOn] IS NULL")); + + Execute.Sql(SqlServerOnlineIndex.Create("IX_DepartmentDataProtectionMigrations_Department_Kind", + "DepartmentDataProtectionMigrations", new[] { "[DepartmentId] ASC", "[Kind] ASC", "[CompletedOn] ASC" })); + + if (!Schema.Table("DepartmentProtectedDataEgressPolicies").Exists()) + Create.Table("DepartmentProtectedDataEgressPolicies") + .WithColumn("DepartmentProtectedDataEgressPolicyId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("PushMode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("EmailMode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("SmsMode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("VoiceMode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("PinChallengeExpiryMinutes").AsInt32().NotNullable().WithDefaultValue(5) + .WithColumn("PinMaxAttempts").AsInt32().NotNullable().WithDefaultValue(3) + .WithColumn("PinLockoutMinutes").AsInt32().NotNullable().WithDefaultValue(15) + .WithColumn("AcknowledgementVersion").AsString(64).Nullable() + .WithColumn("AcknowledgedByUserId").AsString(128).Nullable() + .WithColumn("AcknowledgedOn").AsDateTime2().Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("UpdatedOn").AsDateTime2().Nullable() + .WithColumn("UpdatedByUserId").AsString(128).Nullable(); + + Execute.Sql(SqlServerOnlineIndex.Create("UX_DepartmentProtectedDataEgressPolicies_DepartmentId", + "DepartmentProtectedDataEgressPolicies", new[] { "[DepartmentId] ASC" }, unique: true)); + + if (!Schema.Table("DepartmentMemberSensitiveData").Exists()) + Create.Table("DepartmentMemberSensitiveData") + .WithColumn("DepartmentMemberSensitiveDataId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("ProtectionId").AsString(64).NotNullable() + .WithColumn("IdentificationNumber").AsString(int.MaxValue).Nullable() + .WithColumn("EmergencyContactName").AsString(int.MaxValue).Nullable() + .WithColumn("EmergencyContactPhone").AsString(int.MaxValue).Nullable() + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("ProtectedCatalogVersion").AsInt32().Nullable() + .WithColumn("CreatedOn").AsDateTime2().NotNullable() + .WithColumn("UpdatedOn").AsDateTime2().Nullable(); + + Execute.Sql(SqlServerOnlineIndex.Create("UX_DepartmentMemberSensitiveData_Department_User", + "DepartmentMemberSensitiveData", new[] { "[DepartmentId] ASC", "[UserId] ASC" }, unique: true)); + + Execute.Sql(SqlServerOnlineIndex.Create("IX_DepartmentMemberSensitiveData_UserId", + "DepartmentMemberSensitiveData", new[] { "[UserId] ASC" })); + } + + public override void Down() + { + // Down drops inert Phase 1 schema. NEVER run this against a department whose durable state + // has left Disabled — DepartmentDataProtectionKeys rows are the only path to that + // department's ciphertext. + if (Schema.Table("DepartmentMemberSensitiveData").Exists()) + Delete.Table("DepartmentMemberSensitiveData"); + if (Schema.Table("DepartmentProtectedDataEgressPolicies").Exists()) + Delete.Table("DepartmentProtectedDataEgressPolicies"); + if (Schema.Table("DepartmentDataProtectionMigrations").Exists()) + Delete.Table("DepartmentDataProtectionMigrations"); + if (Schema.Table("DepartmentDataProtectionKeys").Exists()) + Delete.Table("DepartmentDataProtectionKeys"); + if (Schema.Table("DepartmentDataProtectionPolicies").Exists()) + Delete.Table("DepartmentDataProtectionPolicies"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0125_AddDepartmentOperationLocks.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0125_AddDepartmentOperationLocks.cs new file mode 100644 index 000000000..f70c8b22e --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0125_AddDepartmentOperationLocks.cs @@ -0,0 +1,49 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Department operation lock — a department-wide mutation freeze (reads continue) held by the ADP + /// migration worker during an active overnight migration window, designed as a general platform + /// mechanism. The filtered unique index enforces at most one active lock per department at the + /// database, closing the acquire race. Runs outside a migration transaction for the ONLINE index + /// build; every statement is existence-guarded for safe retry. + /// + [Migration(125, TransactionBehavior.None)] + public class M0125_AddDepartmentOperationLocks : Migration + { + public override void Up() + { + if (!Schema.Table("DepartmentOperationLocks").Exists()) + Create.Table("DepartmentOperationLocks") + .WithColumn("DepartmentOperationLockId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("LockType").AsInt32().NotNullable() + .WithColumn("Reason").AsString(512).Nullable() + .WithColumn("CorrelationId").AsString(128).Nullable() + .WithColumn("AppliedUtc").AsDateTime2().NotNullable() + .WithColumn("AppliedByIdentity").AsString(256).Nullable() + .WithColumn("HeartbeatUtc").AsDateTime2().NotNullable() + .WithColumn("ExpiresUtc").AsDateTime2().NotNullable() + .WithColumn("ProjectedEndUtc").AsDateTime2().Nullable() + .WithColumn("ReleasedUtc").AsDateTime2().Nullable() + .WithColumn("ReleasedBy").AsString(256).Nullable() + .WithColumn("ReleaseKind").AsInt32().Nullable(); + + // At most one active lock per department, enforced at the database. + Execute.Sql(SqlServerOnlineIndex.Create("UX_DepartmentOperationLocks_Department_Active", + "DepartmentOperationLocks", new[] { "[DepartmentId] ASC" }, unique: true, + filter: "[ReleasedUtc] IS NULL")); + + // Liveness sweep: find active locks whose safety valve has passed. + Execute.Sql(SqlServerOnlineIndex.Create("IX_DepartmentOperationLocks_Released_Expires", + "DepartmentOperationLocks", new[] { "[ReleasedUtc] ASC", "[ExpiresUtc] ASC" })); + } + + public override void Down() + { + if (Schema.Table("DepartmentOperationLocks").Exists()) + Delete.Table("DepartmentOperationLocks"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0126_SeedAdpFeatureFlagAndAddon.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0126_SeedAdpFeatureFlagAndAddon.cs new file mode 100644 index 000000000..925aa6eb3 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0126_SeedAdpFeatureFlagAndAddon.cs @@ -0,0 +1,53 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Seeds the Advanced Data Protection (ADP) commercialization rows: + /// 1. The "Security.DepartmentProtectedDataEnrollment" feature flag — the global enrollment + /// admission gate. Seeded OFF, permanent and operator-managed; it gates NEW enrollment only and + /// is never consulted for runtime crypto, grants, rotation, or opt-out of already-enabled + /// departments. No percentage rollout or targeting is ever applied to this key. + /// 2. The yearly single-tier ADP PlanAddon row (PlanAddonTypes.ADP = 2, $999/yr launch price). + /// ExternalId carries the Stripe yearly price id (under product prod_V9NRrdSq5hxCk8). + /// TestExternalId seeds empty until the Stripe test-mode counterpart exists. The Paddle + /// price id is NOT stored here — per the PTT precedent it lives in + /// PaymentProviderConfig.PaddleAdpAddon (pri_01m11vm50c17z0rxcgy4fppf80, product + /// pro_01m11vjn9cjmgmwzgv2kt8wndk). + /// + [Migration(126)] + public class M0126_SeedAdpFeatureFlagAndAddon : Migration + { + // Keep FlagKey in sync with Resgrid.Model.FeatureFlagKeys.DepartmentProtectedDataEnrollment. + private const string FlagKey = "Security.DepartmentProtectedDataEnrollment"; + + // Fixed id so every data center's row matches (same convention as the M0023 PTT addon row). + private const string AdpPlanAddonId = "b3a4f9d2-6c1e-4f8a-9d27-5e9c1b7a4a02"; + + public override void Up() + { + // Guarded with IF NOT EXISTS so re-running the migration does not violate the unique + // FlagKey index. IsPermanent = 1: the generic admin UI must not archive or delete this key. + Execute.Sql( + "IF NOT EXISTS (SELECT 1 FROM [FeatureFlags] WHERE [FlagKey] = '" + FlagKey + "') " + + "INSERT INTO [FeatureFlags] ([FlagKey], [Name], [Description], [Category], [IsEnabledGlobally], [IsPermanent]) " + + "VALUES ('" + FlagKey + "', " + + "'ADP Enrollment Admission', " + + "'Global admission gate for Advanced Data Protection enrollment. When on, departments with an active paid ADP addon may enroll via the wizard; when off, no new enrollment commits anywhere. Gates new enrollment only - never runtime crypto, grants, rotation or opt-out of enabled departments. Operator-managed; no percentage rollout or targeting.', " + + "'Security', 0, 1);"); + + Execute.Sql( + "IF NOT EXISTS (SELECT 1 FROM [PlanAddons] WHERE [PlanAddonId] = '" + AdpPlanAddonId + "') " + + "INSERT INTO [PlanAddons] ([PlanAddonId], [AddonType], [Cost], [ExternalId], [TestExternalId]) " + + "VALUES ('" + AdpPlanAddonId + "', 2, 999, 'price_0U94gcqJFDZJcnkVOJNe9SnR', '');"); + } + + public override void Down() + { + // Deliberate no-op. Up() tolerates pre-existing rows (WHERE NOT EXISTS), so this + // migration cannot prove it inserted them — deleting by key on rollback could destroy an + // operator-created enrollment gate or a live, billed addon row. The flag is also seeded + // IsPermanent: nothing may delete it. Re-applying Up() after a rollback is a no-op. + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0127_WidenProtectedCandidateColumns.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0127_WidenProtectedCandidateColumns.cs new file mode 100644 index 000000000..97f990870 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0127_WidenProtectedCandidateColumns.cs @@ -0,0 +1,53 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// ADP plan section 22.2 pre-enrollment capacity migration (SQL Server only — PostgreSQL uses + /// unbounded citext everywhere, so it has a matching no-op migration for version parity). + /// An AES-GCM envelope "rgdp:1:{v}:{base64(nonce|tag|ciphertext)}" needs roughly + /// 1.4 × plaintext + 70 characters, so bounded caps like 150/4000 cannot hold envelopes of + /// near-cap plaintext; every cataloged bounded string column goes to NVARCHAR(MAX). + /// Widening NVARCHAR(n) to MAX is metadata-only (no data rewrite). Plaintext user-input + /// validation constants and NOT NULL constraints are unchanged — capacity is the only change. + /// None of these columns is an index key or INCLUDE (verified in plan section 22.4). + /// + [Migration(127)] + public class M0127_WidenProtectedCandidateColumns : Migration + { + public override void Up() + { + Alter.Table("Calls").AlterColumn("DeletedReason").AsString(int.MaxValue).Nullable(); + Alter.Table("CallNotes").AlterColumn("FlaggedReason").AsString(int.MaxValue).Nullable(); + Alter.Table("CallAttachments").AlterColumn("FlaggedReason").AsString(int.MaxValue).Nullable(); + Alter.Table("CallAttachments").AlterColumn("Name").AsString(int.MaxValue).Nullable(); + Alter.Table("CallReferences").AlterColumn("Note").AsString(int.MaxValue).Nullable(); + Alter.Table("CallLogs").AlterColumn("Narrative").AsString(int.MaxValue).NotNullable(); + Alter.Table("Logs").AlterColumn("Narrative").AsString(int.MaxValue).NotNullable(); + Alter.Table("UnitLogs").AlterColumn("Narrative").AsString(int.MaxValue).NotNullable(); + Alter.Table("UserStates").AlterColumn("Note").AsString(int.MaxValue).Nullable(); + Alter.Table("Messages").AlterColumn("Subject").AsString(int.MaxValue).NotNullable(); + Alter.Table("Messages").AlterColumn("Body").AsString(int.MaxValue).NotNullable(); + + // Linked addresses enter the catalog (section 5.1); Address1 is already MAX after M0085. + Alter.Table("Addresses").AlterColumn("City").AsString(int.MaxValue).Nullable(); + Alter.Table("Addresses").AlterColumn("State").AsString(int.MaxValue).Nullable(); + Alter.Table("Addresses").AlterColumn("PostalCode").AsString(int.MaxValue).Nullable(); + Alter.Table("Addresses").AlterColumn("Country").AsString(int.MaxValue).Nullable(); + + // Mailbox credentials get envelope-encrypted under the credential-hygiene item (22.1). + Alter.Table("DistributionLists").AlterColumn("Username").AsString(int.MaxValue).Nullable(); + Alter.Table("DistributionLists").AlterColumn("Password").AsString(int.MaxValue).Nullable(); + + // Cataloged with the section 5.3 moderation set. + Alter.Table("ModerationRequests").AlterColumn("OriginalContentType").AsString(int.MaxValue).Nullable(); + } + + public override void Down() + { + // Narrowing back can truncate data and offers no safety benefit; the widened columns are + // valid for plaintext-only departments. Intentionally irreversible. + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0128_AddAdpCompanionColumns.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0128_AddAdpCompanionColumns.cs new file mode 100644 index 000000000..a77b4ae0f --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0128_AddAdpCompanionColumns.cs @@ -0,0 +1,50 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// ADP plan section 22.3 companion-column (Appendix B) pattern for cataloged typed columns that + /// cannot hold a text envelope: CallNotes and CallAttachments coordinates. When a department is + /// protected, the decimal column is nulled and Protected{Name}Envelope carries the rgdp value; + /// IsProtected marks the row. Purely additive and inert while no department is enrolled. + /// (MessageRecipients/UnitStates coordinates follow when their families enter the catalog.) + /// + [Migration(128)] + public class M0128_AddAdpCompanionColumns : Migration + { + public override void Up() + { + if (!Schema.Table("CallNotes").Column("IsProtected").Exists()) + Alter.Table("CallNotes") + .AddColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .AddColumn("ProtectedLatitudeEnvelope").AsString(int.MaxValue).Nullable() + .AddColumn("ProtectedLongitudeEnvelope").AsString(int.MaxValue).Nullable(); + + if (!Schema.Table("CallAttachments").Column("IsProtected").Exists()) + Alter.Table("CallAttachments") + .AddColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .AddColumn("ProtectedLatitudeEnvelope").AsString(int.MaxValue).Nullable() + .AddColumn("ProtectedLongitudeEnvelope").AsString(int.MaxValue).Nullable(); + } + + public override void Down() + { + // Only safe while every department is Disabled; dropping the envelope columns of a + // protected department destroys its coordinate ciphertext. + if (Schema.Table("CallNotes").Column("IsProtected").Exists()) + { + Delete.Column("ProtectedLongitudeEnvelope").FromTable("CallNotes"); + Delete.Column("ProtectedLatitudeEnvelope").FromTable("CallNotes"); + Delete.Column("IsProtected").FromTable("CallNotes"); + } + + if (Schema.Table("CallAttachments").Column("IsProtected").Exists()) + { + Delete.Column("ProtectedLongitudeEnvelope").FromTable("CallAttachments"); + Delete.Column("ProtectedLatitudeEnvelope").FromTable("CallAttachments"); + Delete.Column("IsProtected").FromTable("CallAttachments"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0129_AddAdpCompanionColumnsWave2.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0129_AddAdpCompanionColumnsWave2.cs new file mode 100644 index 000000000..3db36f542 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0129_AddAdpCompanionColumnsWave2.cs @@ -0,0 +1,60 @@ +using System; +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// ADP plan section 22.3 companion-column (Appendix B) pattern, wave 2: MessageRecipients and + /// UnitStates coordinates/telemetry. When a department is protected AND these families enter the + /// protected-field catalog, the typed column is nulled and Protected{Name}Envelope carries the + /// rgdp value; IsProtected marks the row. Purely additive and INERT today — catalog v1 does not + /// include these tables, so no engine touches the new columns until the catalog-v2 bindings ship + /// (section 5.2 decision: unit telemetry is protected when linked to a protected call). + /// + [Migration(129)] + public class M0129_AddAdpCompanionColumnsWave2 : Migration + { + public override void Up() + { + if (!Schema.Table("MessageRecipients").Column("IsProtected").Exists()) + Alter.Table("MessageRecipients") + .AddColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .AddColumn("ProtectedLatitudeEnvelope").AsString(int.MaxValue).Nullable() + .AddColumn("ProtectedLongitudeEnvelope").AsString(int.MaxValue).Nullable(); + + if (!Schema.Table("UnitStates").Column("IsProtected").Exists()) + Alter.Table("UnitStates") + .AddColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .AddColumn("ProtectedLatitudeEnvelope").AsString(int.MaxValue).Nullable() + .AddColumn("ProtectedLongitudeEnvelope").AsString(int.MaxValue).Nullable() + .AddColumn("ProtectedAccuracyEnvelope").AsString(int.MaxValue).Nullable() + .AddColumn("ProtectedAltitudeEnvelope").AsString(int.MaxValue).Nullable() + .AddColumn("ProtectedAltitudeAccuracyEnvelope").AsString(int.MaxValue).Nullable() + .AddColumn("ProtectedSpeedEnvelope").AsString(int.MaxValue).Nullable() + .AddColumn("ProtectedHeadingEnvelope").AsString(int.MaxValue).Nullable(); + } + + public override void Down() + { + // Only safe while every department is Disabled; dropping the envelope columns of a + // protected department destroys its coordinate/telemetry ciphertext. + if (Schema.Table("MessageRecipients").Column("IsProtected").Exists()) + { + Delete.Column("ProtectedLongitudeEnvelope").FromTable("MessageRecipients"); + Delete.Column("ProtectedLatitudeEnvelope").FromTable("MessageRecipients"); + Delete.Column("IsProtected").FromTable("MessageRecipients"); + } + + if (Schema.Table("UnitStates").Column("IsProtected").Exists()) + { + Delete.Column("ProtectedHeadingEnvelope").FromTable("UnitStates"); + Delete.Column("ProtectedSpeedEnvelope").FromTable("UnitStates"); + Delete.Column("ProtectedAltitudeAccuracyEnvelope").FromTable("UnitStates"); + Delete.Column("ProtectedAltitudeEnvelope").FromTable("UnitStates"); + Delete.Column("ProtectedLongitudeEnvelope").FromTable("UnitStates"); + Delete.Column("ProtectedLatitudeEnvelope").FromTable("UnitStates"); + Delete.Column("IsProtected").FromTable("UnitStates"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0124_AddDepartmentDataProtectionPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0124_AddDepartmentDataProtectionPg.cs new file mode 100644 index 000000000..6016191cc --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0124_AddDepartmentDataProtectionPg.cs @@ -0,0 +1,173 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Advanced Data Protection (ADP) Phase 1 schemas: durable per-department protection policy + /// (departmentdataprotectionpolicies.state is the single data-safety truth), wrapped department + /// key versions (never plaintext key material), resumable bulk-migration cursors, independent + /// per-channel egress policy, and department-owned member sensitive data moved off the global + /// userprofiles row. All tables ship inert while every department is Disabled. + /// CREATE INDEX CONCURRENTLY cannot run inside a transaction; every statement is + /// existence-guarded and invalid indexes from interrupted builds are removed before retry. + /// + [Migration(124, TransactionBehavior.None)] + public class M0124_AddDepartmentDataProtectionPg : Migration + { + public override void Up() + { + if (!Schema.Table("departmentdataprotectionpolicies").Exists()) + Create.Table("departmentdataprotectionpolicies") + .WithColumn("departmentdataprotectionpolicyid").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("state").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("catalogversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("activemigrationkind").AsInt32().Nullable() + .WithColumn("stepupwindowminutes").AsInt32().NotNullable().WithDefaultValue(15) + .WithColumn("stepupwindowreason").AsCustom("citext").Nullable() + .WithColumn("policyepoch").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("minimumclientversionsjson").AsCustom("citext").Nullable() + .WithColumn("acknowledgementsjson").AsCustom("citext").Nullable() + .WithColumn("acknowledgedbyuserid").AsCustom("citext").Nullable() + .WithColumn("acknowledgedon").AsDateTime2().Nullable() + .WithColumn("enrollmentflagevaluationjson").AsCustom("citext").Nullable() + .WithColumn("addonbillingreference").AsCustom("citext").Nullable() + .WithColumn("migrationwindowstartlocal").AsCustom("citext").Nullable() + .WithColumn("migrationwindowendlocal").AsCustom("citext").Nullable() + .WithColumn("migrationwindowtimezone").AsCustom("citext").Nullable() + .WithColumn("offboardingeffectiveon").AsDateTime2().Nullable() + .WithColumn("offboardingsource").AsInt32().Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("createdbyuserid").AsCustom("citext").Nullable() + .WithColumn("updatedon").AsDateTime2().Nullable() + .WithColumn("updatedbyuserid").AsCustom("citext").Nullable(); + + if (!Schema.Table("departmentdataprotectionkeys").Exists()) + Create.Table("departmentdataprotectionkeys") + .WithColumn("departmentdataprotectionkeyid").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("version").AsInt32().NotNullable() + .WithColumn("wrappedkey").AsCustom("citext").NotNullable() + .WithColumn("providertype").AsCustom("citext").NotNullable() + .WithColumn("providerkeyreference").AsCustom("citext").NotNullable() + .WithColumn("providerkeyversion").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("status").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("activatedon").AsDateTime2().Nullable() + .WithColumn("retiredon").AsDateTime2().Nullable(); + + if (!Schema.Table("departmentdataprotectionmigrations").Exists()) + Create.Table("departmentdataprotectionmigrations") + .WithColumn("departmentdataprotectionmigrationid").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("kind").AsInt32().NotNullable() + .WithColumn("catalogversion").AsInt32().NotNullable() + .WithColumn("targetkeyversion").AsInt32().Nullable() + .WithColumn("targettable").AsCustom("citext").NotNullable() + .WithColumn("cursor").AsCustom("citext").Nullable() + .WithColumn("rowstotal").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("rowsprocessed").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("rowsalreadyprotected").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("rowsanomalous").AsInt64().NotNullable().WithDefaultValue(0L) + .WithColumn("verificationstate").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("attempts").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("lasterrorcode").AsCustom("citext").Nullable() + .WithColumn("correlationid").AsCustom("citext").Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("startedon").AsDateTime2().Nullable() + .WithColumn("checkpointedon").AsDateTime2().Nullable() + .WithColumn("completedon").AsDateTime2().Nullable(); + + if (!Schema.Table("departmentprotecteddataegresspolicies").Exists()) + Create.Table("departmentprotecteddataegresspolicies") + .WithColumn("departmentprotecteddataegresspolicyid").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("pushmode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("emailmode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("smsmode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("voicemode").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("pinchallengeexpiryminutes").AsInt32().NotNullable().WithDefaultValue(5) + .WithColumn("pinmaxattempts").AsInt32().NotNullable().WithDefaultValue(3) + .WithColumn("pinlockoutminutes").AsInt32().NotNullable().WithDefaultValue(15) + .WithColumn("acknowledgementversion").AsCustom("citext").Nullable() + .WithColumn("acknowledgedbyuserid").AsCustom("citext").Nullable() + .WithColumn("acknowledgedon").AsDateTime2().Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("updatedon").AsDateTime2().Nullable() + .WithColumn("updatedbyuserid").AsCustom("citext").Nullable(); + + if (!Schema.Table("departmentmembersensitivedata").Exists()) + Create.Table("departmentmembersensitivedata") + .WithColumn("departmentmembersensitivedataid").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("userid").AsCustom("citext").NotNullable() + .WithColumn("protectionid").AsCustom("citext").NotNullable() + .WithColumn("identificationnumber").AsCustom("citext").Nullable() + .WithColumn("emergencycontactname").AsCustom("citext").Nullable() + .WithColumn("emergencycontactphone").AsCustom("citext").Nullable() + .WithColumn("notes").AsCustom("citext").Nullable() + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("protectedcatalogversion").AsInt32().Nullable() + .WithColumn("createdon").AsDateTime2().NotNullable() + .WithColumn("updatedon").AsDateTime2().Nullable(); + + RemoveInvalidIndexes(); + + // Raw IF NOT EXISTS statements so they execute after invalid-index cleanup; FluentMigrator + // evaluates Schema.Index.Exists while collecting expressions, before the cleanup SQL runs. + Execute.Sql("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ux_departmentdataprotectionpolicies_departmentid ON departmentdataprotectionpolicies (departmentid);"); + Execute.Sql("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ux_departmentdataprotectionkeys_department_version ON departmentdataprotectionkeys (departmentid, version);"); + Execute.Sql("CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_departmentdataprotectionkeys_department_status ON departmentdataprotectionkeys (departmentid, status);"); + Execute.Sql("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ux_departmentdataprotectionmigrations_active ON departmentdataprotectionmigrations (departmentid, kind, targettable) WHERE completedon IS NULL;"); + Execute.Sql("CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_departmentdataprotectionmigrations_department_kind ON departmentdataprotectionmigrations (departmentid, kind, completedon);"); + Execute.Sql("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ux_departmentprotecteddataegresspolicies_departmentid ON departmentprotecteddataegresspolicies (departmentid);"); + Execute.Sql("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ux_departmentmembersensitivedata_department_user ON departmentmembersensitivedata (departmentid, userid);"); + Execute.Sql("CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_departmentmembersensitivedata_userid ON departmentmembersensitivedata (userid);"); + } + + public override void Down() + { + // Down drops inert Phase 1 schema. NEVER run this against a department whose durable state + // has left Disabled — departmentdataprotectionkeys rows are the only path to that + // department's ciphertext. + if (Schema.Table("departmentmembersensitivedata").Exists()) + Delete.Table("departmentmembersensitivedata"); + if (Schema.Table("departmentprotecteddataegresspolicies").Exists()) + Delete.Table("departmentprotecteddataegresspolicies"); + if (Schema.Table("departmentdataprotectionmigrations").Exists()) + Delete.Table("departmentdataprotectionmigrations"); + if (Schema.Table("departmentdataprotectionkeys").Exists()) + Delete.Table("departmentdataprotectionkeys"); + if (Schema.Table("departmentdataprotectionpolicies").Exists()) + Delete.Table("departmentdataprotectionpolicies"); + } + + private void RemoveInvalidIndexes() + { + Execute.Sql(@" + DO $$ + DECLARE invalid_index record; + BEGIN + FOR invalid_index IN + SELECT n.nspname AS schema_name, c.relname AS index_name + FROM pg_class c + JOIN pg_index i ON i.indexrelid = c.oid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() + AND c.relname IN ( + 'ux_departmentdataprotectionpolicies_departmentid', + 'ux_departmentdataprotectionkeys_department_version', + 'ix_departmentdataprotectionkeys_department_status', + 'ux_departmentdataprotectionmigrations_active', + 'ix_departmentdataprotectionmigrations_department_kind', + 'ux_departmentprotecteddataegresspolicies_departmentid', + 'ux_departmentmembersensitivedata_department_user', + 'ix_departmentmembersensitivedata_userid') + AND NOT i.indisvalid + LOOP + EXECUTE format('DROP INDEX %I.%I', invalid_index.schema_name, invalid_index.index_name); + END LOOP; + END $$;"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0125_AddDepartmentOperationLocksPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0125_AddDepartmentOperationLocksPg.cs new file mode 100644 index 000000000..ae6f646ad --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0125_AddDepartmentOperationLocksPg.cs @@ -0,0 +1,70 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Department operation lock — a department-wide mutation freeze (reads continue) held by the ADP + /// migration worker during an active overnight migration window, designed as a general platform + /// mechanism. The partial unique index enforces at most one active lock per department at the + /// database, closing the acquire race. CREATE INDEX CONCURRENTLY cannot run inside a transaction; + /// statements are existence-guarded and invalid indexes are removed before retry. + /// + [Migration(125, TransactionBehavior.None)] + public class M0125_AddDepartmentOperationLocksPg : Migration + { + public override void Up() + { + if (!Schema.Table("departmentoperationlocks").Exists()) + Create.Table("departmentoperationlocks") + .WithColumn("departmentoperationlockid").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("locktype").AsInt32().NotNullable() + .WithColumn("reason").AsCustom("citext").Nullable() + .WithColumn("correlationid").AsCustom("citext").Nullable() + .WithColumn("appliedutc").AsDateTime2().NotNullable() + .WithColumn("appliedbyidentity").AsCustom("citext").Nullable() + .WithColumn("heartbeatutc").AsDateTime2().NotNullable() + .WithColumn("expiresutc").AsDateTime2().NotNullable() + .WithColumn("projectedendutc").AsDateTime2().Nullable() + .WithColumn("releasedutc").AsDateTime2().Nullable() + .WithColumn("releasedby").AsCustom("citext").Nullable() + .WithColumn("releasekind").AsInt32().Nullable(); + + RemoveInvalidIndexes(); + + // At most one active lock per department, enforced at the database. + Execute.Sql("CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS ux_departmentoperationlocks_department_active ON departmentoperationlocks (departmentid) WHERE releasedutc IS NULL;"); + + // Liveness sweep: find active locks whose safety valve has passed. + Execute.Sql("CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_departmentoperationlocks_released_expires ON departmentoperationlocks (releasedutc, expiresutc);"); + } + + public override void Down() + { + if (Schema.Table("departmentoperationlocks").Exists()) + Delete.Table("departmentoperationlocks"); + } + + private void RemoveInvalidIndexes() + { + Execute.Sql(@" + DO $$ + DECLARE invalid_index record; + BEGIN + FOR invalid_index IN + SELECT n.nspname AS schema_name, c.relname AS index_name + FROM pg_class c + JOIN pg_index i ON i.indexrelid = c.oid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = current_schema() + AND c.relname IN ( + 'ux_departmentoperationlocks_department_active', + 'ix_departmentoperationlocks_released_expires') + AND NOT i.indisvalid + LOOP + EXECUTE format('DROP INDEX %I.%I', invalid_index.schema_name, invalid_index.index_name); + END LOOP; + END $$;"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0126_SeedAdpFeatureFlagAndAddonPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0126_SeedAdpFeatureFlagAndAddonPg.cs new file mode 100644 index 000000000..5be900c7c --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0126_SeedAdpFeatureFlagAndAddonPg.cs @@ -0,0 +1,54 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Seeds the Advanced Data Protection (ADP) commercialization rows: + /// 1. The "Security.DepartmentProtectedDataEnrollment" feature flag — the global enrollment + /// admission gate. Seeded OFF, permanent and operator-managed; it gates NEW enrollment only and + /// is never consulted for runtime crypto, grants, rotation, or opt-out of already-enabled + /// departments. No percentage rollout or targeting is ever applied to this key. + /// 2. The yearly single-tier ADP PlanAddon row (PlanAddonTypes.ADP = 2, $999/yr launch price). + /// ExternalId carries the Stripe yearly price id (under product prod_V9NRrdSq5hxCk8). + /// TestExternalId seeds empty until the Stripe test-mode counterpart exists. The Paddle + /// price id is NOT stored here — per the PTT precedent it lives in + /// PaymentProviderConfig.PaddleAdpAddon (pri_01m11vm50c17z0rxcgy4fppf80, product + /// pro_01m11vjn9cjmgmwzgv2kt8wndk). + /// + [Migration(126)] + public class M0126_SeedAdpFeatureFlagAndAddonPg : Migration + { + // Keep FlagKey in sync with Resgrid.Model.FeatureFlagKeys.DepartmentProtectedDataEnrollment. + private const string FlagKey = "Security.DepartmentProtectedDataEnrollment"; + + // Fixed id so every data center's row matches (same convention as the M0023 PTT addon row). + private const string AdpPlanAddonId = "b3a4f9d2-6c1e-4f8a-9d27-5e9c1b7a4a02"; + + public override void Up() + { + // Guarded with WHERE NOT EXISTS so re-running the migration does not violate the unique + // flagkey index. ispermanent = true: the generic admin UI must not archive or delete this + // key. The identity PK is omitted so Postgres assigns it. + Execute.Sql( + "INSERT INTO featureflags (flagkey, name, description, category, isenabledglobally, ispermanent) " + + "SELECT '" + FlagKey + "', " + + "'ADP Enrollment Admission', " + + "'Global admission gate for Advanced Data Protection enrollment. When on, departments with an active paid ADP addon may enroll via the wizard; when off, no new enrollment commits anywhere. Gates new enrollment only - never runtime crypto, grants, rotation or opt-out of enabled departments. Operator-managed; no percentage rollout or targeting.', " + + "'Security', false, true " + + "WHERE NOT EXISTS (SELECT 1 FROM featureflags WHERE flagkey = '" + FlagKey + "');"); + + Execute.Sql( + "INSERT INTO planaddons (planaddonid, addontype, cost, externalid, testexternalid) " + + "SELECT '" + AdpPlanAddonId + "', 2, 999, 'price_0U94gcqJFDZJcnkVOJNe9SnR', '' " + + "WHERE NOT EXISTS (SELECT 1 FROM planaddons WHERE planaddonid = '" + AdpPlanAddonId + "');"); + } + + public override void Down() + { + // Deliberate no-op. Up() tolerates pre-existing rows (WHERE NOT EXISTS), so this + // migration cannot prove it inserted them — deleting by key on rollback could destroy an + // operator-created enrollment gate or a live, billed addon row. The flag is also seeded + // ispermanent: nothing may delete it. Re-applying Up() after a rollback is a no-op. + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0127_WidenProtectedCandidateColumnsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0127_WidenProtectedCandidateColumnsPg.cs new file mode 100644 index 000000000..bbab4feda --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0127_WidenProtectedCandidateColumnsPg.cs @@ -0,0 +1,23 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Version-parity no-op for the SQL Server M0127 capacity migration (ADP plan section 22.2). + /// PostgreSQL stores every audited string column as unbounded citext, so no resizes are required + /// on this engine — the rgdp envelope (roughly 1.4 × plaintext + 70 characters) always fits. + /// + [Migration(127)] + public class M0127_WidenProtectedCandidateColumnsPg : Migration + { + public override void Up() + { + // Intentionally empty; see summary. + } + + public override void Down() + { + // Intentionally empty; see summary. + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0128_AddAdpCompanionColumnsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0128_AddAdpCompanionColumnsPg.cs new file mode 100644 index 000000000..5d7518d7f --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0128_AddAdpCompanionColumnsPg.cs @@ -0,0 +1,48 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// ADP plan section 22.3 companion-column (Appendix B) pattern for cataloged typed columns that + /// cannot hold a text envelope: callnotes and callattachments coordinates. When a department is + /// protected, the decimal column is nulled and protected{name}envelope carries the rgdp value; + /// isprotected marks the row. Purely additive and inert while no department is enrolled. + /// + [Migration(128)] + public class M0128_AddAdpCompanionColumnsPg : Migration + { + public override void Up() + { + if (!Schema.Table("callnotes").Column("isprotected").Exists()) + Alter.Table("callnotes") + .AddColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .AddColumn("protectedlatitudeenvelope").AsCustom("citext").Nullable() + .AddColumn("protectedlongitudeenvelope").AsCustom("citext").Nullable(); + + if (!Schema.Table("callattachments").Column("isprotected").Exists()) + Alter.Table("callattachments") + .AddColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .AddColumn("protectedlatitudeenvelope").AsCustom("citext").Nullable() + .AddColumn("protectedlongitudeenvelope").AsCustom("citext").Nullable(); + } + + public override void Down() + { + // Only safe while every department is Disabled; dropping the envelope columns of a + // protected department destroys its coordinate ciphertext. + if (Schema.Table("callnotes").Column("isprotected").Exists()) + { + Delete.Column("protectedlongitudeenvelope").FromTable("callnotes"); + Delete.Column("protectedlatitudeenvelope").FromTable("callnotes"); + Delete.Column("isprotected").FromTable("callnotes"); + } + + if (Schema.Table("callattachments").Column("isprotected").Exists()) + { + Delete.Column("protectedlongitudeenvelope").FromTable("callattachments"); + Delete.Column("protectedlatitudeenvelope").FromTable("callattachments"); + Delete.Column("isprotected").FromTable("callattachments"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0129_AddAdpCompanionColumnsWave2Pg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0129_AddAdpCompanionColumnsWave2Pg.cs new file mode 100644 index 000000000..4ea930959 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0129_AddAdpCompanionColumnsWave2Pg.cs @@ -0,0 +1,58 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// ADP plan section 22.3 companion-column (Appendix B) pattern, wave 2: messagerecipients and + /// unitstates coordinates/telemetry. When a department is protected AND these families enter the + /// protected-field catalog, the typed column is nulled and protected{name}envelope carries the + /// rgdp value; isprotected marks the row. Purely additive and INERT today — catalog v1 does not + /// include these tables, so no engine touches the new columns until the catalog-v2 bindings ship. + /// + [Migration(129)] + public class M0129_AddAdpCompanionColumnsWave2Pg : Migration + { + public override void Up() + { + if (!Schema.Table("messagerecipients").Column("isprotected").Exists()) + Alter.Table("messagerecipients") + .AddColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .AddColumn("protectedlatitudeenvelope").AsCustom("citext").Nullable() + .AddColumn("protectedlongitudeenvelope").AsCustom("citext").Nullable(); + + if (!Schema.Table("unitstates").Column("isprotected").Exists()) + Alter.Table("unitstates") + .AddColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .AddColumn("protectedlatitudeenvelope").AsCustom("citext").Nullable() + .AddColumn("protectedlongitudeenvelope").AsCustom("citext").Nullable() + .AddColumn("protectedaccuracyenvelope").AsCustom("citext").Nullable() + .AddColumn("protectedaltitudeenvelope").AsCustom("citext").Nullable() + .AddColumn("protectedaltitudeaccuracyenvelope").AsCustom("citext").Nullable() + .AddColumn("protectedspeedenvelope").AsCustom("citext").Nullable() + .AddColumn("protectedheadingenvelope").AsCustom("citext").Nullable(); + } + + public override void Down() + { + // Only safe while every department is Disabled; dropping the envelope columns of a + // protected department destroys its coordinate/telemetry ciphertext. + if (Schema.Table("messagerecipients").Column("isprotected").Exists()) + { + Delete.Column("protectedlongitudeenvelope").FromTable("messagerecipients"); + Delete.Column("protectedlatitudeenvelope").FromTable("messagerecipients"); + Delete.Column("isprotected").FromTable("messagerecipients"); + } + + if (Schema.Table("unitstates").Column("isprotected").Exists()) + { + Delete.Column("protectedheadingenvelope").FromTable("unitstates"); + Delete.Column("protectedspeedenvelope").FromTable("unitstates"); + Delete.Column("protectedaltitudeaccuracyenvelope").FromTable("unitstates"); + Delete.Column("protectedaltitudeenvelope").FromTable("unitstates"); + Delete.Column("protectedlongitudeenvelope").FromTable("unitstates"); + Delete.Column("protectedlatitudeenvelope").FromTable("unitstates"); + Delete.Column("isprotected").FromTable("unitstates"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.ProtectedData/OpenBaoTransitKeyWrappingProvider.cs b/Providers/Resgrid.Providers.ProtectedData/OpenBaoTransitKeyWrappingProvider.cs new file mode 100644 index 000000000..4d4a30a0a --- /dev/null +++ b/Providers/Resgrid.Providers.ProtectedData/OpenBaoTransitKeyWrappingProvider.cs @@ -0,0 +1,259 @@ +using System; +using System.Globalization; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.ProtectedData +{ + /// + /// OpenBao Transit implementation of (ADP plan sections 2.4, + /// 4.1, 13, A.8-A.9). Authenticates with the cert auth method over mTLS and uses exactly the two + /// paths the broker policy grants: transit/datakey/wrapped/{key} and transit/decrypt/{key}, both + /// with the mandatory per-department derived-key context (context = base64(DepartmentId)), so + /// cross-department unwrap fails cryptographically at the KMS, not merely in broker code. + /// + /// Fail-closed by design: authentication failure, HTTP errors, timeouts and malformed responses + /// all throw — there is no fallback token, no cached credential, and no degradation to local + /// crypto. Tokens are short-lived; an expiring token is replaced by a fresh cert login rather + /// than renewed, so a revoked certificate stops service at the next refresh. This type belongs + /// on Protected Data Broker hosts ONLY (register via ProtectedDataProviderModule); Web/API/worker + /// hosts keep the fail-closed NotConfiguredKeyWrappingProvider. + /// + public class OpenBaoTransitKeyWrappingProvider : IKeyWrappingProvider, IDisposable + { + // Refresh the token this many seconds before its lease actually expires. + private const int TokenRefreshSkewSeconds = 60; + + private readonly HttpClient _httpClient; + private readonly SemaphoreSlim _tokenLock = new SemaphoreSlim(1, 1); + private string _token; + private DateTime _tokenExpiresUtc = DateTime.MinValue; + + public OpenBaoTransitKeyWrappingProvider() + : this(CreateMtlsHandler()) + { + } + + /// Test seam: inject a message handler; production uses the mTLS handler above. + public OpenBaoTransitKeyWrappingProvider(HttpMessageHandler handler) + { + if (string.IsNullOrWhiteSpace(DataProtectionConfig.OpenBaoAddress)) + throw new InvalidOperationException("DataProtectionConfig.OpenBaoAddress is not configured."); + + var baseAddress = new Uri(DataProtectionConfig.OpenBaoAddress.TrimEnd('/') + "/"); + + // A plaintext endpoint would carry the cert-auth token AND the unwrapped DEK unencrypted + // (plan A.14 prohibited configurations) — refuse it outright. + if (!string.Equals(baseAddress.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("DataProtectionConfig.OpenBaoAddress must use HTTPS; plaintext KMS transport is prohibited."); + + _httpClient = new HttpClient(handler, disposeHandler: true) + { + BaseAddress = baseAddress, + Timeout = TimeSpan.FromMilliseconds(DataProtectionConfig.OpenBaoTimeoutMs > 0 + ? DataProtectionConfig.OpenBaoTimeoutMs + : 10000) + }; + } + + public string ProviderType => "OpenBaoTransit"; + + public async Task GenerateWrappedDataKeyAsync(int departmentId, CancellationToken cancellationToken = default) + { + var mount = DataProtectionConfig.OpenBaoTransitMount; + var keyName = DataProtectionConfig.OpenBaoTransitKeyName; + + var response = await SendAuthenticatedAsync( + $"v1/{mount}/datakey/wrapped/{keyName}", + new JObject + { + ["context"] = DepartmentContext(departmentId), + ["bits"] = 256 + }, cancellationToken); + + var ciphertext = response["data"]?["ciphertext"]?.Value(); + if (string.IsNullOrWhiteSpace(ciphertext)) + throw new CryptographicException("OpenBao datakey/wrapped returned no ciphertext."); + + return new WrappedDataKey + { + // The vault:vN:... token IS the wrapped blob; stored verbatim and handed back to + // transit/decrypt unchanged. + WrappedKeyBase64 = ciphertext, + ProviderType = ProviderType, + ProviderKeyReference = $"{mount}/{keyName}", + ProviderKeyVersion = response["data"]?["key_version"]?.Value() ?? 0 + }; + } + + public async Task UnwrapDataKeyAsync(int departmentId, string wrappedKeyBase64, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(wrappedKeyBase64)) + throw new ArgumentException("Wrapped key is required.", nameof(wrappedKeyBase64)); + + var mount = DataProtectionConfig.OpenBaoTransitMount; + var keyName = DataProtectionConfig.OpenBaoTransitKeyName; + + var response = await SendAuthenticatedAsync( + $"v1/{mount}/decrypt/{keyName}", + new JObject + { + ["ciphertext"] = wrappedKeyBase64, + ["context"] = DepartmentContext(departmentId) + }, cancellationToken); + + var plaintextBase64 = response["data"]?["plaintext"]?.Value(); + if (string.IsNullOrWhiteSpace(plaintextBase64)) + throw new CryptographicException("OpenBao decrypt returned no plaintext."); + + // Decode straight into pinned memory so the caller's ZeroMemory is not defeated by GC + // compaction copies; the intermediate managed buffer from FromBase64String is avoided. + var byteCount = ComputeBase64ByteCount(plaintextBase64); + var dek = GC.AllocateArray(byteCount, pinned: true); + if (!Convert.TryFromBase64String(plaintextBase64, dek, out var written) || written != byteCount) + { + CryptographicOperations.ZeroMemory(dek); + throw new CryptographicException("OpenBao decrypt returned malformed plaintext encoding."); + } + + return dek; + } + + /// Per-department derived-key context: base64(DepartmentId), matching the A.8 ceremony. + private static string DepartmentContext(int departmentId) => + Convert.ToBase64String(Encoding.UTF8.GetBytes(departmentId.ToString(CultureInfo.InvariantCulture))); + + private async Task SendAuthenticatedAsync(string path, JObject body, CancellationToken cancellationToken) + { + var token = await EnsureTokenAsync(cancellationToken); + + using var request = new HttpRequestMessage(HttpMethod.Post, path) + { + Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json") + }; + request.Headers.Add("X-Vault-Token", token); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + // Never log the request body, token, or response content — value-free status only. + Logging.LogError($"OpenBao request to '{path}' failed with status {(int)response.StatusCode}; failing closed."); + throw new CryptographicException($"OpenBao request failed with status {(int)response.StatusCode}."); + } + + try + { + return JObject.Parse(content); + } + catch (JsonReaderException) + { + throw new CryptographicException("OpenBao returned a malformed response."); + } + } + + private async Task EnsureTokenAsync(CancellationToken cancellationToken) + { + if (TokenIsFresh()) + return _token; + + await _tokenLock.WaitAsync(cancellationToken); + try + { + if (TokenIsFresh()) + return _token; + + // A fresh cert login replaces the expiring token instead of renewing it, so a revoked + // broker certificate stops service at the next refresh. Login failure fails closed; + // there is deliberately no cached or long-lived fallback (plan section 2.2). + var loginBody = new JObject(); + if (!string.IsNullOrWhiteSpace(DataProtectionConfig.OpenBaoCertAuthRoleName)) + loginBody["name"] = DataProtectionConfig.OpenBaoCertAuthRoleName; + + using var request = new HttpRequestMessage(HttpMethod.Post, "v1/auth/cert/login") + { + Content = new StringContent(loginBody.ToString(Formatting.None), Encoding.UTF8, "application/json") + }; + + using var response = await _httpClient.SendAsync(request, cancellationToken); + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + Logging.LogError($"OpenBao cert login failed with status {(int)response.StatusCode}; protected operations fail closed."); + throw new CryptographicException($"OpenBao authentication failed with status {(int)response.StatusCode}."); + } + + JObject parsed; + try + { + parsed = JObject.Parse(content); + } + catch (JsonReaderException) + { + throw new CryptographicException("OpenBao authentication returned a malformed response."); + } + + var token = parsed["auth"]?["client_token"]?.Value(); + var leaseSeconds = parsed["auth"]?["lease_duration"]?.Value() ?? 0; + if (string.IsNullOrWhiteSpace(token) || leaseSeconds <= 0) + throw new CryptographicException("OpenBao authentication returned no usable token."); + + _token = token; + _tokenExpiresUtc = DateTime.UtcNow.AddSeconds(Math.Max(leaseSeconds - TokenRefreshSkewSeconds, 1)); + return _token; + } + finally + { + _tokenLock.Release(); + } + } + + private bool TokenIsFresh() => !string.IsNullOrEmpty(_token) && DateTime.UtcNow < _tokenExpiresUtc; + + private static int ComputeBase64ByteCount(string base64) + { + var padding = 0; + if (base64.EndsWith("==", StringComparison.Ordinal)) + padding = 2; + else if (base64.EndsWith("=", StringComparison.Ordinal)) + padding = 1; + + return (base64.Length * 3 / 4) - padding; + } + + private static SocketsHttpHandler CreateMtlsHandler() + { + if (string.IsNullOrWhiteSpace(DataProtectionConfig.OpenBaoClientCertificatePath)) + throw new InvalidOperationException( + "DataProtectionConfig.OpenBaoClientCertificatePath is not configured. The OpenBao cert auth method requires the broker's mTLS client certificate."); + + var certificate = string.IsNullOrEmpty(DataProtectionConfig.OpenBaoClientCertificatePassword) + ? X509CertificateLoader.LoadPkcs12FromFile(DataProtectionConfig.OpenBaoClientCertificatePath, null) + : X509CertificateLoader.LoadPkcs12FromFile(DataProtectionConfig.OpenBaoClientCertificatePath, + DataProtectionConfig.OpenBaoClientCertificatePassword); + + var handler = new SocketsHttpHandler(); + handler.SslOptions.ClientCertificates = new X509CertificateCollection { certificate }; + return handler; + } + + public void Dispose() + { + _httpClient.Dispose(); + _tokenLock.Dispose(); + } + } +} diff --git a/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs new file mode 100644 index 000000000..2746218a5 --- /dev/null +++ b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClient.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.ProtectedData +{ + /// + /// HTTP client for the Protected Data Broker's field-crypto endpoints (ADP plan section 3.1 + /// steps 7-9). Safe on Web/API hosts: it carries no key material and performs no cryptography — + /// it forwards ciphertext/plaintext plus the caller's grant and the workload key, and maps every + /// transport fault to a closed failure (broker_unavailable) with no partial results. Field + /// values are never logged here; error handling touches only status codes and value-free error + /// codes. + /// + public class ProtectedDataBrokerClient : IProtectedDataBrokerClient, IDisposable + { + internal const string WorkloadKeyHeader = "X-Resgrid-Broker-Key"; + internal const string BrokerUnavailableErrorCode = "broker_unavailable"; + + private readonly HttpClient _httpClient; + + public ProtectedDataBrokerClient() + : this(new HttpClientHandler()) + { + } + + /// Test seam: inject a message handler. + public ProtectedDataBrokerClient(HttpMessageHandler handler) + { + _httpClient = new HttpClient(handler, disposeHandler: true) + { + Timeout = TimeSpan.FromMilliseconds(DataProtectionConfig.BrokerTimeoutMs > 0 + ? DataProtectionConfig.BrokerTimeoutMs + : 10000) + }; + } + + public bool IsConfigured => TryGetHttpsBaseUri(out _); + + /// + /// The broker base URI, HTTPS only: requests carry the workload key, the caller's grant and + /// protected field values — a plaintext http endpoint would expose all three, so it reads as + /// "no broker configured" (fail closed) with a value-free log. + /// + private static bool TryGetHttpsBaseUri(out Uri baseUri) + { + baseUri = null; + var configured = DataProtectionConfig.BrokerBaseUrl; + if (string.IsNullOrWhiteSpace(configured)) + return false; + + if (!Uri.TryCreate(configured.TrimEnd('/') + "/", UriKind.Absolute, out baseUri)) + { + Logging.LogError("DataProtectionConfig.BrokerBaseUrl is not a valid absolute URI; treating the broker as unconfigured."); + baseUri = null; + return false; + } + + if (!string.Equals(baseUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + Logging.LogError("DataProtectionConfig.BrokerBaseUrl must use HTTPS; plaintext broker transport is prohibited. Treating the broker as unconfigured."); + baseUri = null; + return false; + } + + return true; + } + + public async Task IsHealthyAsync(CancellationToken cancellationToken = default) + { + if (!TryGetHttpsBaseUri(out var baseUri)) + return false; + + try + { + using var response = await _httpClient.GetAsync(new Uri(baseUri, "health"), cancellationToken); + return response.IsSuccessStatusCode; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is UriFormatException) + { + return false; + } + } + + public Task DecryptAsync(int departmentId, string grantToken, string requestId, + IReadOnlyList items, CancellationToken cancellationToken = default) => + SendAsync("api/v1/broker/decrypt", departmentId, grantToken, requestId, items, cancellationToken); + + public Task EncryptAsync(int departmentId, string grantToken, string requestId, + IReadOnlyList items, CancellationToken cancellationToken = default) => + SendAsync("api/v1/broker/encrypt", departmentId, grantToken, requestId, items, cancellationToken); + + private async Task SendAsync(string path, int departmentId, string grantToken, + string requestId, IReadOnlyList items, CancellationToken cancellationToken) + { + // HTTPS-only, enforced per request: the payload carries the workload key, the grant and + // protected field values. A non-HTTPS configuration fails closed here. + if (!TryGetHttpsBaseUri(out var baseUri)) + return Failed(BrokerUnavailableErrorCode); + + try + { + var payload = JsonConvert.SerializeObject(new + { + departmentId, + grantToken, + requestId, + items + }); + + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri(baseUri, path)) + { + Content = new StringContent(payload, Encoding.UTF8, "application/json") + }; + request.Headers.TryAddWithoutValidation(WorkloadKeyHeader, DataProtectionConfig.BrokerApiKey); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + // The broker's failure body is a value-free result with an error code; surface it + // when parseable, else the generic closed failure. + var failure = TryDeserialize(body); + if (failure != null && !string.IsNullOrWhiteSpace(failure.ErrorCode)) + return Failed(failure.ErrorCode); + + Logging.LogError($"Protected Data Broker call {path} failed with HTTP {(int)response.StatusCode}."); + return Failed(BrokerUnavailableErrorCode); + } + + var result = TryDeserialize(body); + if (result == null) + { + Logging.LogError($"Protected Data Broker call {path} returned an unparseable body."); + return Failed(BrokerUnavailableErrorCode); + } + + return result; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is UriFormatException) + { + Logging.LogError($"Protected Data Broker call {path} failed: {ex.GetType().Name}."); + return Failed(BrokerUnavailableErrorCode); + } + } + + private static ProtectedDataBrokerResult TryDeserialize(string body) + { + try + { + return JsonConvert.DeserializeObject(body); + } + catch (JsonException) + { + return null; + } + } + + private static ProtectedDataBrokerResult Failed(string errorCode) => + new ProtectedDataBrokerResult { Success = false, ErrorCode = errorCode }; + + public void Dispose() => _httpClient.Dispose(); + } +} diff --git a/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClientModule.cs b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClientModule.cs new file mode 100644 index 000000000..1d172b4c6 --- /dev/null +++ b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataBrokerClientModule.cs @@ -0,0 +1,19 @@ +using Autofac; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.ProtectedData +{ + /// + /// Registers the application-tier broker CLIENT. Unlike ProtectedDataProviderModule (broker + /// hosts only), this module is safe anywhere: the client holds no key material and can only ask + /// the broker to act on a caller's grant. Load it in Web/API composition roots that serve + /// protected reads/writes. + /// + public class ProtectedDataBrokerClientModule : Module + { + protected override void Load(ContainerBuilder builder) + { + builder.RegisterType().As().SingleInstance(); + } + } +} diff --git a/Providers/Resgrid.Providers.ProtectedData/ProtectedDataProviderModule.cs b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataProviderModule.cs new file mode 100644 index 000000000..3ea740b1f --- /dev/null +++ b/Providers/Resgrid.Providers.ProtectedData/ProtectedDataProviderModule.cs @@ -0,0 +1,22 @@ +using Autofac; +using Resgrid.Config; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.ProtectedData +{ + /// + /// Registers the real KMS adapter. Load this module ONLY in the Protected Data Broker host — + /// never in Web, API, worker, or BackOffice composition roots (plan section 2.2: those hosts have + /// no KMS role, no route, and keep the fail-closed NotConfiguredKeyWrappingProvider that + /// ServicesModule registers). Autofac's last-registration-wins means loading this module after + /// ServicesModule replaces that placeholder on the broker host. + /// + public class ProtectedDataProviderModule : Module + { + protected override void Load(ContainerBuilder builder) + { + if (string.Equals(DataProtectionConfig.KeyWrappingProviderType, "OpenBaoTransit", System.StringComparison.OrdinalIgnoreCase)) + builder.RegisterType().As().SingleInstance(); + } + } +} diff --git a/Providers/Resgrid.Providers.ProtectedData/Resgrid.Providers.ProtectedData.csproj b/Providers/Resgrid.Providers.ProtectedData/Resgrid.Providers.ProtectedData.csproj new file mode 100644 index 000000000..ee41e4f68 --- /dev/null +++ b/Providers/Resgrid.Providers.ProtectedData/Resgrid.Providers.ProtectedData.csproj @@ -0,0 +1,18 @@ + + + net9.0 + Debug;Release;Docker + + + + + + + + + + + + + + diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs new file mode 100644 index 000000000..bb639d95e --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionBulkRepository.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + /// + /// Bulk data access for the ADP migration engine. See + /// for the contract. All identifiers are + /// rendered from code-reviewed AdpTableBinding constants; values are always Dapper parameters. + /// + public class DepartmentDataProtectionBulkRepository : IDepartmentDataProtectionBulkRepository + { + // ASCII bytes of the rgdpb: binary envelope header, for prefix compares. + private static readonly byte[] BinaryPrefixBytes = Encoding.ASCII.GetBytes("rgdpb:"); + + private readonly IConnectionProvider _connectionProvider; + private readonly string _schema; + private readonly bool _isPostgres; + + public DepartmentDataProtectionBulkRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration) + { + _connectionProvider = connectionProvider; + _schema = sqlConfiguration.SchemaName; + _isPostgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + } + + public async Task CountRowsAsync(AdpTableBinding binding, int departmentId, + CancellationToken cancellationToken = default) + { + var sql = $"SELECT COUNT_BIG(*) FROM {Table(binding.TableName)} WHERE {Scope(binding)}"; + if (_isPostgres) + sql = sql.Replace("COUNT_BIG", "COUNT"); + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteScalarAsync(new Dapper.CommandDefinition(sql, + new { DepartmentId = departmentId }, cancellationToken: cancellationToken)); + } + + public async Task> GetBatchAsync(AdpTableBinding binding, int departmentId, + string afterCursor, int batchSize, CancellationToken cancellationToken = default) + { + var columns = SelectColumns(binding); + var columnList = string.Join(", ", columns.Select(Ident)); + var cursorClause = string.IsNullOrEmpty(afterCursor) ? "" : $" AND {Ident(binding.PkColumn)} > @After"; + + var sql = _isPostgres + ? $"SELECT {Ident(binding.PkColumn)} AS pk, {columnList} FROM {Table(binding.TableName)} WHERE {Scope(binding)}{cursorClause} ORDER BY {Ident(binding.PkColumn)} LIMIT @BatchSize" + : $"SELECT TOP (@BatchSize) {Ident(binding.PkColumn)} AS pk, {columnList} FROM {Table(binding.TableName)} WHERE {Scope(binding)}{cursorClause} ORDER BY {Ident(binding.PkColumn)}"; + + var parameters = new DynamicParameters(); + parameters.Add("DepartmentId", departmentId); + parameters.Add("BatchSize", batchSize); + if (!string.IsNullOrEmpty(afterCursor)) + { + if (binding.PkIsNumeric) + parameters.Add("After", long.Parse(afterCursor, CultureInfo.InvariantCulture)); + else + parameters.Add("After", afterCursor); + } + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(cancellationToken); + var rows = await connection.QueryAsync(new Dapper.CommandDefinition(sql, parameters, cancellationToken: cancellationToken)); + + var result = new List(); + foreach (IDictionary raw in rows) + { + var row = new AdpBulkFieldRow + { + RowKey = Convert.ToString(raw["pk"], CultureInfo.InvariantCulture) + }; + + // PostgreSQL returns lowercase column keys; map back to the binding's casing. + foreach (var column in columns) + { + var key = raw.Keys.FirstOrDefault(k => string.Equals(k, column, StringComparison.OrdinalIgnoreCase)); + row.Values[column] = key == null ? null : raw[key]; + } + + result.Add(row); + } + + return result; + } + + public async Task ApplyBatchAsync(AdpTableBinding binding, IReadOnlyList updates, + int departmentDataProtectionMigrationId, string newCursor, long rowsProcessedDelta, + long rowsAlreadyProtectedDelta, long rowsAnomalousDelta, CancellationToken cancellationToken) + { + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(cancellationToken); + + // One transaction for the row writes AND the cursor advance: a crash between batches + // re-processes at most one batch, and re-processing is a no-op under the + // double-encryption guard. + using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + if (updates != null) + { + foreach (var update in updates) + { + if (update.SetValues.Count == 0) + continue; + + var setColumns = update.SetValues.Keys.ToList(); + var setClause = string.Join(", ", setColumns.Select((c, i) => $"{Ident(c)} = @v{i}")); + + var parameters = new DynamicParameters(); + for (var i = 0; i < setColumns.Count; i++) + parameters.Add($"v{i}", update.SetValues[setColumns[i]]); + + if (binding.PkIsNumeric) + parameters.Add("RowKey", long.Parse(update.RowKey, CultureInfo.InvariantCulture)); + else + parameters.Add("RowKey", update.RowKey); + + var sql = $"UPDATE {Table(binding.TableName)} SET {setClause} WHERE {Ident(binding.PkColumn)} = @RowKey"; + await connection.ExecuteAsync(new Dapper.CommandDefinition(sql, parameters, transaction, cancellationToken: cancellationToken)); + } + } + + var migrationSql = _isPostgres + ? $"UPDATE {Table("DepartmentDataProtectionMigrations")} SET cursor = @Cursor, rowsprocessed = rowsprocessed + @Processed, rowsalreadyprotected = rowsalreadyprotected + @AlreadyProtected, rowsanomalous = rowsanomalous + @Anomalous, checkpointedon = @UtcNow WHERE departmentdataprotectionmigrationid = @MigrationId" + : $"UPDATE {Table("DepartmentDataProtectionMigrations")} SET [Cursor] = @Cursor, [RowsProcessed] = [RowsProcessed] + @Processed, [RowsAlreadyProtected] = [RowsAlreadyProtected] + @AlreadyProtected, [RowsAnomalous] = [RowsAnomalous] + @Anomalous, [CheckpointedOn] = @UtcNow WHERE [DepartmentDataProtectionMigrationId] = @MigrationId"; + + await connection.ExecuteAsync(new Dapper.CommandDefinition(migrationSql, new + { + Cursor = newCursor, + Processed = rowsProcessedDelta, + AlreadyProtected = rowsAlreadyProtectedDelta, + Anomalous = rowsAnomalousDelta, + UtcNow = DateTime.UtcNow, + MigrationId = departmentDataProtectionMigrationId + }, transaction, cancellationToken: cancellationToken)); + + await transaction.CommitAsync(cancellationToken); + } + + public async Task CountTextResidueAsync(AdpTableBinding binding, int departmentId, bool enveloped, + CancellationToken cancellationToken = default) + { + var textColumns = binding.Columns.Where(c => c.StorageKind == ProtectedFieldStorageKind.Text).ToList(); + if (textColumns.Count == 0) + return 0; + + // Note: citext makes LIKE case-insensitive on PostgreSQL. Envelopes are always written + // lowercase, so the enveloped scan can only over-match a value that already starts with + // "rgdp:" in some casing — which the plaintext scan would equally have to treat as + // suspect. Acceptable for a residue gate that requires zero. + var predicates = textColumns.Select(c => enveloped + ? $"({Ident(c.ColumnName)} LIKE 'rgdp:%')" + : $"({Ident(c.ColumnName)} IS NOT NULL AND {Ident(c.ColumnName)} <> '' AND {Ident(c.ColumnName)} NOT LIKE 'rgdp:%')"); + + return await CountWhereAsync(binding, departmentId, string.Join(" OR ", predicates), cancellationToken); + } + + public async Task CountBinaryResidueAsync(AdpTableBinding binding, int departmentId, bool enveloped, + CancellationToken cancellationToken = default) + { + var binaryColumns = binding.Columns.Where(c => c.StorageKind == ProtectedFieldStorageKind.Binary).ToList(); + if (binaryColumns.Count == 0) + return 0; + + var prefixHex = "0x" + Convert.ToHexString(BinaryPrefixBytes); + var predicates = binaryColumns.Select(c => + { + var prefixMatch = _isPostgres + ? $"substring({Ident(c.ColumnName)} from 1 for {BinaryPrefixBytes.Length}) = '\\x{Convert.ToHexString(BinaryPrefixBytes).ToLowerInvariant()}'::bytea" + : $"SUBSTRING({Ident(c.ColumnName)}, 1, {BinaryPrefixBytes.Length}) = {prefixHex}"; + + return enveloped + ? $"({Ident(c.ColumnName)} IS NOT NULL AND {prefixMatch})" + : $"({Ident(c.ColumnName)} IS NOT NULL AND NOT ({prefixMatch}))"; + }); + + return await CountWhereAsync(binding, departmentId, string.Join(" OR ", predicates), cancellationToken); + } + + public async Task CountCompanionResidueAsync(AdpTableBinding binding, int departmentId, bool enveloped, + CancellationToken cancellationToken = default) + { + var companionColumns = binding.Columns.Where(c => c.StorageKind == ProtectedFieldStorageKind.CompanionColumn).ToList(); + if (companionColumns.Count == 0) + return 0; + + // Enrollment residue: the typed column still holds a value. Offboarding residue: the + // companion envelope column still holds a value. + var predicates = companionColumns.Select(c => enveloped + ? $"({Ident(c.CompanionColumn)} IS NOT NULL)" + : $"({Ident(c.ColumnName)} IS NOT NULL)"); + + return await CountWhereAsync(binding, departmentId, string.Join(" OR ", predicates), cancellationToken); + } + + private async Task CountWhereAsync(AdpTableBinding binding, int departmentId, string predicate, + CancellationToken cancellationToken) + { + var count = _isPostgres ? "COUNT(*)" : "COUNT_BIG(*)"; + var sql = $"SELECT {count} FROM {Table(binding.TableName)} WHERE {Scope(binding)} AND ({predicate})"; + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(cancellationToken); + return await connection.ExecuteScalarAsync(new Dapper.CommandDefinition(sql, + new { DepartmentId = departmentId }, cancellationToken: cancellationToken)); + } + + private List SelectColumns(AdpTableBinding binding) + { + var columns = new List(); + foreach (var spec in binding.Columns) + { + columns.Add(spec.ColumnName); + if (spec.StorageKind == ProtectedFieldStorageKind.CompanionColumn) + columns.Add(spec.CompanionColumn); + } + + if (!string.IsNullOrEmpty(binding.ProtectedMarkerColumn)) + columns.Add(binding.ProtectedMarkerColumn); + + return columns.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + private string Scope(AdpTableBinding binding) + { + if (!string.IsNullOrEmpty(binding.DepartmentColumn)) + return $"{Ident(binding.DepartmentColumn)} = @DepartmentId"; + + return $"{Ident(binding.ParentFkColumn)} IN (SELECT {Ident(binding.ParentPkColumn)} FROM {Table(binding.ParentTable)} WHERE {Ident("DepartmentId")} = @DepartmentId)"; + } + + private string Table(string name) => + _isPostgres ? $"{_schema}.{name.ToLowerInvariant()}" : $"{_schema}.[{name}]"; + + private string Ident(string name) => + _isPostgres ? name.ToLowerInvariant() : $"[{name}]"; + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionKeyRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionKeyRepository.cs new file mode 100644 index 000000000..450db04e1 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionKeyRepository.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class DepartmentDataProtectionKeyRepository : RepositoryBase, IDepartmentDataProtectionKeyRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IUnitOfWork _unitOfWork; + private readonly string _table; + private readonly bool _isPostgres; + + public DepartmentDataProtectionKeyRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _unitOfWork = unitOfWork; + _isPostgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = _isPostgres + ? $"{sqlConfiguration.SchemaName}.departmentdataprotectionkeys" + : $"{sqlConfiguration.SchemaName}.[DepartmentDataProtectionKeys]"; + } + + public Task GetActiveByDepartmentIdAsync(int departmentId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId AND status = @Status ORDER BY version DESC LIMIT 1" + : $"SELECT TOP 1 * FROM {_table} WHERE [DepartmentId] = @DepartmentId AND [Status] = @Status ORDER BY [Version] DESC"; + return WithConnectionAsync(connection => connection.QueryFirstOrDefaultAsync( + sql, new { DepartmentId = departmentId, Status = (int)DepartmentDataProtectionKeyStatus.Active }, _unitOfWork?.Transaction)); + } + + public Task GetByDepartmentAndVersionAsync(int departmentId, int version) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId AND version = @Version" + : $"SELECT * FROM {_table} WHERE [DepartmentId] = @DepartmentId AND [Version] = @Version"; + return WithConnectionAsync(connection => connection.QueryFirstOrDefaultAsync( + sql, new { DepartmentId = departmentId, Version = version }, _unitOfWork?.Transaction)); + } + + public async Task> GetAllVersionsByDepartmentIdAsync(int departmentId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId ORDER BY version DESC" + : $"SELECT * FROM {_table} WHERE [DepartmentId] = @DepartmentId ORDER BY [Version] DESC"; + var keys = await WithConnectionAsync(connection => connection.QueryAsync( + sql, new { DepartmentId = departmentId }, _unitOfWork?.Transaction)); + return keys.ToList(); + } + + private async Task WithConnectionAsync(Func> operation) + { + if (_unitOfWork?.Connection != null) + return await operation(_unitOfWork.CreateOrGetConnection()); + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await operation(connection); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionMigrationRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionMigrationRepository.cs new file mode 100644 index 000000000..fef669569 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionMigrationRepository.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class DepartmentDataProtectionMigrationRepository : RepositoryBase, IDepartmentDataProtectionMigrationRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IUnitOfWork _unitOfWork; + private readonly string _table; + private readonly bool _isPostgres; + + public DepartmentDataProtectionMigrationRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _unitOfWork = unitOfWork; + _isPostgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = _isPostgres + ? $"{sqlConfiguration.SchemaName}.departmentdataprotectionmigrations" + : $"{sqlConfiguration.SchemaName}.[DepartmentDataProtectionMigrations]"; + } + + public async Task> GetActiveByDepartmentIdAsync(int departmentId, + DepartmentDataProtectionMigrationKind kind) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId AND kind = @Kind AND completedon IS NULL ORDER BY targettable" + : $"SELECT * FROM {_table} WHERE [DepartmentId] = @DepartmentId AND [Kind] = @Kind AND [CompletedOn] IS NULL ORDER BY [TargetTable]"; + var rows = await WithConnectionAsync(connection => connection.QueryAsync( + sql, new { DepartmentId = departmentId, Kind = (int)kind }, _unitOfWork?.Transaction)); + return rows.ToList(); + } + + public Task GetActiveByDepartmentAndTableAsync(int departmentId, + DepartmentDataProtectionMigrationKind kind, string targetTable) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId AND kind = @Kind AND targettable = @TargetTable AND completedon IS NULL" + : $"SELECT * FROM {_table} WHERE [DepartmentId] = @DepartmentId AND [Kind] = @Kind AND [TargetTable] = @TargetTable AND [CompletedOn] IS NULL"; + return WithConnectionAsync(connection => connection.QueryFirstOrDefaultAsync( + sql, new { DepartmentId = departmentId, Kind = (int)kind, TargetTable = targetTable }, _unitOfWork?.Transaction)); + } + + private async Task WithConnectionAsync(Func> operation) + { + if (_unitOfWork?.Connection != null) + return await operation(_unitOfWork.CreateOrGetConnection()); + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await operation(connection); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionPolicyRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionPolicyRepository.cs new file mode 100644 index 000000000..183ecb685 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentDataProtectionPolicyRepository.cs @@ -0,0 +1,89 @@ +using System; +using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class DepartmentDataProtectionPolicyRepository : RepositoryBase, IDepartmentDataProtectionPolicyRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IUnitOfWork _unitOfWork; + private readonly string _table; + private readonly bool _isPostgres; + + public DepartmentDataProtectionPolicyRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _unitOfWork = unitOfWork; + _isPostgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = _isPostgres + ? $"{sqlConfiguration.SchemaName}.departmentdataprotectionpolicies" + : $"{sqlConfiguration.SchemaName}.[DepartmentDataProtectionPolicies]"; + } + + public Task GetByDepartmentIdAsync(int departmentId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId" + : $"SELECT * FROM {_table} WHERE [DepartmentId] = @DepartmentId"; + return WithConnectionAsync(connection => connection.QueryFirstOrDefaultAsync( + sql, new { DepartmentId = departmentId }, _unitOfWork?.Transaction)); + } + + public Task TryTransitionStateAsync(int departmentId, DepartmentDataProtectionState expectedState, + DepartmentDataProtectionState newState, int? activeMigrationKind, string updatedByUserId, + CancellationToken cancellationToken) + { + var sql = _isPostgres + ? $"UPDATE {_table} SET state = @NewState, activemigrationkind = @ActiveMigrationKind, updatedon = @UtcNow, updatedbyuserid = @UpdatedByUserId WHERE departmentid = @DepartmentId AND state = @ExpectedState" + : $"UPDATE {_table} SET [State] = @NewState, [ActiveMigrationKind] = @ActiveMigrationKind, [UpdatedOn] = @UtcNow, [UpdatedByUserId] = @UpdatedByUserId WHERE [DepartmentId] = @DepartmentId AND [State] = @ExpectedState"; + + return WithConnectionAsync(connection => connection.ExecuteAsync(new Dapper.CommandDefinition(sql, new + { + DepartmentId = departmentId, + ExpectedState = (int)expectedState, + NewState = (int)newState, + ActiveMigrationKind = activeMigrationKind, + UtcNow = DateTime.UtcNow, + UpdatedByUserId = updatedByUserId + }, _unitOfWork?.Transaction, cancellationToken: cancellationToken))); + } + + public async Task IncrementPolicyEpochAsync(int departmentId, string updatedByUserId, CancellationToken cancellationToken) + { + var sql = _isPostgres + ? $"UPDATE {_table} SET policyepoch = policyepoch + 1, updatedon = @UtcNow, updatedbyuserid = @UpdatedByUserId WHERE departmentid = @DepartmentId RETURNING policyepoch" + : $"UPDATE {_table} SET [PolicyEpoch] = [PolicyEpoch] + 1, [UpdatedOn] = @UtcNow, [UpdatedByUserId] = @UpdatedByUserId OUTPUT INSERTED.[PolicyEpoch] WHERE [DepartmentId] = @DepartmentId"; + + var epoch = await WithConnectionAsync(connection => connection.QueryFirstOrDefaultAsync( + new Dapper.CommandDefinition(sql, new + { + DepartmentId = departmentId, + UtcNow = DateTime.UtcNow, + UpdatedByUserId = updatedByUserId + }, _unitOfWork?.Transaction, cancellationToken: cancellationToken))); + + return epoch ?? 0; + } + + private async Task WithConnectionAsync(Func> operation) + { + if (_unitOfWork?.Connection != null) + return await operation(_unitOfWork.CreateOrGetConnection()); + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await operation(connection); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberSensitiveDataRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberSensitiveDataRepository.cs new file mode 100644 index 000000000..7a940cd2c --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberSensitiveDataRepository.cs @@ -0,0 +1,52 @@ +using System; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class DepartmentMemberSensitiveDataRepository : RepositoryBase, IDepartmentMemberSensitiveDataRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IUnitOfWork _unitOfWork; + private readonly string _table; + private readonly bool _isPostgres; + + public DepartmentMemberSensitiveDataRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _unitOfWork = unitOfWork; + _isPostgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = _isPostgres + ? $"{sqlConfiguration.SchemaName}.departmentmembersensitivedata" + : $"{sqlConfiguration.SchemaName}.[DepartmentMemberSensitiveData]"; + } + + public Task GetByDepartmentAndUserAsync(int departmentId, string userId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId AND userid = @UserId" + : $"SELECT * FROM {_table} WHERE [DepartmentId] = @DepartmentId AND [UserId] = @UserId"; + return WithConnectionAsync(connection => connection.QueryFirstOrDefaultAsync( + sql, new { DepartmentId = departmentId, UserId = userId }, _unitOfWork?.Transaction)); + } + + private async Task WithConnectionAsync(Func> operation) + { + if (_unitOfWork?.Connection != null) + return await operation(_unitOfWork.CreateOrGetConnection()); + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await operation(connection); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentOperationLockRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentOperationLockRepository.cs new file mode 100644 index 000000000..2fded8d52 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentOperationLockRepository.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class DepartmentOperationLockRepository : RepositoryBase, IDepartmentOperationLockRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IUnitOfWork _unitOfWork; + private readonly string _table; + private readonly bool _isPostgres; + + public DepartmentOperationLockRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _unitOfWork = unitOfWork; + _isPostgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = _isPostgres + ? $"{sqlConfiguration.SchemaName}.departmentoperationlocks" + : $"{sqlConfiguration.SchemaName}.[DepartmentOperationLocks]"; + } + + public Task GetActiveByDepartmentIdAsync(int departmentId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId AND releasedutc IS NULL" + : $"SELECT * FROM {_table} WHERE [DepartmentId] = @DepartmentId AND [ReleasedUtc] IS NULL"; + return WithConnectionAsync(connection => connection.QueryFirstOrDefaultAsync( + sql, new { DepartmentId = departmentId }, _unitOfWork?.Transaction)); + } + + public async Task> GetAllActiveAsync() + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE releasedutc IS NULL ORDER BY appliedutc" + : $"SELECT * FROM {_table} WHERE [ReleasedUtc] IS NULL ORDER BY [AppliedUtc]"; + var locks = await WithConnectionAsync(connection => connection.QueryAsync( + sql, null, _unitOfWork?.Transaction)); + return locks.ToList(); + } + + /// + /// The INSERT is guarded twice: a NOT EXISTS predicate loses gracefully in the common case, and + /// the filtered/partial unique index on (DepartmentId) WHERE ReleasedUtc IS NULL closes the race + /// two concurrent acquirers could otherwise win under snapshot isolation. On PostgreSQL the + /// index race is absorbed in-statement with ON CONFLICT DO NOTHING — an exception there would + /// poison an enclosing transaction (25P02) and make the recovery re-read impossible. On SQL + /// Server a unique-index violation is mapped to false by re-reading the active lock rather than + /// by parsing provider-specific error codes. + /// + public async Task TryAcquireAsync(DepartmentOperationLock departmentLock, CancellationToken cancellationToken) + { + if (departmentLock == null) + throw new ArgumentNullException(nameof(departmentLock)); + + Utf8WriteGuard.Sanitize(departmentLock); + + var sql = _isPostgres + ? $@"INSERT INTO {_table} (departmentid, locktype, reason, correlationid, appliedutc, appliedbyidentity, heartbeatutc, expiresutc, projectedendutc) + SELECT @DepartmentId, @LockType, @Reason, @CorrelationId, @AppliedUtc, @AppliedByIdentity, @HeartbeatUtc, @ExpiresUtc, @ProjectedEndUtc + WHERE NOT EXISTS (SELECT 1 FROM {_table} WHERE departmentid = @DepartmentId AND releasedutc IS NULL) + ON CONFLICT (departmentid) WHERE releasedutc IS NULL DO NOTHING + RETURNING departmentoperationlockid" + : $@"INSERT INTO {_table} ([DepartmentId], [LockType], [Reason], [CorrelationId], [AppliedUtc], [AppliedByIdentity], [HeartbeatUtc], [ExpiresUtc], [ProjectedEndUtc]) + OUTPUT INSERTED.[DepartmentOperationLockId] + SELECT @DepartmentId, @LockType, @Reason, @CorrelationId, @AppliedUtc, @AppliedByIdentity, @HeartbeatUtc, @ExpiresUtc, @ProjectedEndUtc + WHERE NOT EXISTS (SELECT 1 FROM {_table} WHERE [DepartmentId] = @DepartmentId AND [ReleasedUtc] IS NULL)"; + + try + { + var id = await WithConnectionAsync(connection => connection.QueryFirstOrDefaultAsync( + new Dapper.CommandDefinition(sql, new + { + departmentLock.DepartmentId, + departmentLock.LockType, + departmentLock.Reason, + departmentLock.CorrelationId, + departmentLock.AppliedUtc, + departmentLock.AppliedByIdentity, + departmentLock.HeartbeatUtc, + departmentLock.ExpiresUtc, + departmentLock.ProjectedEndUtc + }, _unitOfWork?.Transaction, cancellationToken: cancellationToken))); + + if (id == null) + return false; + + departmentLock.DepartmentOperationLockId = id.Value; + return true; + } + catch (DbException) when (!_isPostgres) + { + // SQL Server only: a concurrent acquirer beat this one through the unique index. If an + // active lock now exists this is the expected lost race; anything else is a real fault. + // PostgreSQL never takes this path — ON CONFLICT absorbs the race in-statement, and a + // re-read inside a now-failed transaction would only raise 25P02. + var active = await GetActiveByDepartmentIdAsync(departmentLock.DepartmentId); + if (active != null) + return false; + + throw; + } + } + + public Task HeartbeatAsync(int departmentOperationLockId, DateTime heartbeatUtc, DateTime? newExpiresUtc, + CancellationToken cancellationToken) + { + var sql = _isPostgres + ? $"UPDATE {_table} SET heartbeatutc = @HeartbeatUtc, expiresutc = COALESCE(@NewExpiresUtc, expiresutc) WHERE departmentoperationlockid = @Id AND releasedutc IS NULL" + : $"UPDATE {_table} SET [HeartbeatUtc] = @HeartbeatUtc, [ExpiresUtc] = COALESCE(@NewExpiresUtc, [ExpiresUtc]) WHERE [DepartmentOperationLockId] = @Id AND [ReleasedUtc] IS NULL"; + + return WithConnectionAsync(connection => connection.ExecuteAsync(new Dapper.CommandDefinition(sql, new + { + Id = departmentOperationLockId, + HeartbeatUtc = heartbeatUtc, + NewExpiresUtc = newExpiresUtc + }, _unitOfWork?.Transaction, cancellationToken: cancellationToken))); + } + + public Task ReleaseAsync(int departmentOperationLockId, DepartmentOperationLockReleaseKind kind, + string releasedBy, DateTime releasedUtc, CancellationToken cancellationToken) + { + var sql = _isPostgres + ? $"UPDATE {_table} SET releasedutc = @ReleasedUtc, releasedby = @ReleasedBy, releasekind = @ReleaseKind WHERE departmentoperationlockid = @Id AND releasedutc IS NULL" + : $"UPDATE {_table} SET [ReleasedUtc] = @ReleasedUtc, [ReleasedBy] = @ReleasedBy, [ReleaseKind] = @ReleaseKind WHERE [DepartmentOperationLockId] = @Id AND [ReleasedUtc] IS NULL"; + + return WithConnectionAsync(connection => connection.ExecuteAsync(new Dapper.CommandDefinition(sql, new + { + Id = departmentOperationLockId, + ReleasedUtc = releasedUtc, + ReleasedBy = releasedBy, + ReleaseKind = (int)kind + }, _unitOfWork?.Transaction, cancellationToken: cancellationToken))); + } + + private async Task WithConnectionAsync(Func> operation) + { + if (_unitOfWork?.Connection != null) + return await operation(_unitOfWork.CreateOrGetConnection()); + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await operation(connection); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentProtectedDataEgressPolicyRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentProtectedDataEgressPolicyRepository.cs new file mode 100644 index 000000000..549d71994 --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentProtectedDataEgressPolicyRepository.cs @@ -0,0 +1,52 @@ +using System; +using System.Data.Common; +using System.Threading.Tasks; +using Dapper; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Repositories.Connection; +using Resgrid.Model.Repositories.Queries; +using Resgrid.Repositories.DataRepository.Configs; + +namespace Resgrid.Repositories.DataRepository +{ + public class DepartmentProtectedDataEgressPolicyRepository : RepositoryBase, IDepartmentProtectedDataEgressPolicyRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IUnitOfWork _unitOfWork; + private readonly string _table; + private readonly bool _isPostgres; + + public DepartmentProtectedDataEgressPolicyRepository(IConnectionProvider connectionProvider, SqlConfiguration sqlConfiguration, + IUnitOfWork unitOfWork, IQueryFactory queryFactory) + : base(connectionProvider, sqlConfiguration, unitOfWork, queryFactory) + { + _connectionProvider = connectionProvider; + _unitOfWork = unitOfWork; + _isPostgres = DataConfig.DatabaseType == DatabaseTypes.Postgres; + _table = _isPostgres + ? $"{sqlConfiguration.SchemaName}.departmentprotecteddataegresspolicies" + : $"{sqlConfiguration.SchemaName}.[DepartmentProtectedDataEgressPolicies]"; + } + + public Task GetByDepartmentIdAsync(int departmentId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId" + : $"SELECT * FROM {_table} WHERE [DepartmentId] = @DepartmentId"; + return WithConnectionAsync(connection => connection.QueryFirstOrDefaultAsync( + sql, new { DepartmentId = departmentId }, _unitOfWork?.Transaction)); + } + + private async Task WithConnectionAsync(Func> operation) + { + if (_unitOfWork?.Connection != null) + return await operation(_unitOfWork.CreateOrGetConnection()); + + using var connection = _connectionProvider.Create(); + await connection.OpenAsync(); + return await operation(connection); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index b3a6dad66..abe3e724d 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -262,6 +262,15 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // Advanced Data Protection (ADP) Repositories + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + // Workflow Repositories builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Resgrid.sln b/Resgrid.sln index ae31cfccf..c3c73edc5 100644 --- a/Resgrid.sln +++ b/Resgrid.sln @@ -116,6 +116,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Tracking.Tests", "T EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Web.Common", "Web\Resgrid.Web.Common\Resgrid.Web.Common.csproj", "{7B0856E5-C37C-4EA6-9A69-FDF5F772B249}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Providers.ProtectedData", "Providers\Resgrid.Providers.ProtectedData\Resgrid.Providers.ProtectedData.csproj", "{B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Web.Broker", "Web\Resgrid.Web.Broker\Resgrid.Web.Broker.csproj", "{19AD2004-864D-4E68-8B3F-CE7116BD84AF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Azure|Any CPU = Azure|Any CPU @@ -1686,6 +1690,78 @@ Global {7B0856E5-C37C-4EA6-9A69-FDF5F772B249}.Staging|x86.Build.0 = Debug|Any CPU {7B0856E5-C37C-4EA6-9A69-FDF5F772B249}.Staging|x64.ActiveCfg = Debug|Any CPU {7B0856E5-C37C-4EA6-9A69-FDF5F772B249}.Staging|x64.Build.0 = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Azure|Any CPU.ActiveCfg = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Azure|Any CPU.Build.0 = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Azure|x86.ActiveCfg = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Azure|x86.Build.0 = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Azure|x64.ActiveCfg = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Azure|x64.Build.0 = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Cloud|Any CPU.Build.0 = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Cloud|x86.ActiveCfg = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Cloud|x86.Build.0 = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Cloud|x64.ActiveCfg = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Cloud|x64.Build.0 = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Debug|x86.Build.0 = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Debug|x64.ActiveCfg = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Debug|x64.Build.0 = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Docker|Any CPU.ActiveCfg = Docker|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Docker|Any CPU.Build.0 = Docker|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Docker|x86.ActiveCfg = Docker|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Docker|x86.Build.0 = Docker|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Docker|x64.ActiveCfg = Docker|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Docker|x64.Build.0 = Docker|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Release|Any CPU.Build.0 = Release|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Release|x86.ActiveCfg = Release|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Release|x86.Build.0 = Release|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Release|x64.ActiveCfg = Release|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Release|x64.Build.0 = Release|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Staging|Any CPU.ActiveCfg = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Staging|Any CPU.Build.0 = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Staging|x86.ActiveCfg = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Staging|x86.Build.0 = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Staging|x64.ActiveCfg = Debug|Any CPU + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87}.Staging|x64.Build.0 = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Azure|Any CPU.ActiveCfg = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Azure|Any CPU.Build.0 = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Azure|x86.ActiveCfg = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Azure|x86.Build.0 = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Azure|x64.ActiveCfg = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Azure|x64.Build.0 = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Cloud|Any CPU.Build.0 = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Cloud|x86.ActiveCfg = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Cloud|x86.Build.0 = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Cloud|x64.ActiveCfg = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Cloud|x64.Build.0 = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Debug|x86.ActiveCfg = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Debug|x86.Build.0 = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Debug|x64.ActiveCfg = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Debug|x64.Build.0 = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Docker|Any CPU.ActiveCfg = Docker|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Docker|Any CPU.Build.0 = Docker|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Docker|x86.ActiveCfg = Docker|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Docker|x86.Build.0 = Docker|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Docker|x64.ActiveCfg = Docker|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Docker|x64.Build.0 = Docker|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Release|Any CPU.Build.0 = Release|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Release|x86.ActiveCfg = Release|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Release|x86.Build.0 = Release|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Release|x64.ActiveCfg = Release|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Release|x64.Build.0 = Release|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Staging|Any CPU.ActiveCfg = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Staging|Any CPU.Build.0 = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Staging|x86.ActiveCfg = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Staging|x86.Build.0 = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Staging|x64.ActiveCfg = Debug|Any CPU + {19AD2004-864D-4E68-8B3F-CE7116BD84AF}.Staging|x64.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1735,6 +1811,8 @@ Global {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6} = {DBB9862A-C008-4C3F-A9DB-320429E4A07F} {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE} = {D2D96CD8-CD7D-414D-8B33-A6C363B40C8D} {7B0856E5-C37C-4EA6-9A69-FDF5F772B249} = {53B024F9-E293-42F1-BA67-7F68C3F3C243} + {B4BDCDCC-61E8-44F4-B7B9-F4CEC0508E87} = {F06D475C-635C-4DE4-82BA-C49A90BA8FCD} + {19AD2004-864D-4E68-8B3F-CE7116BD84AF} = {53B024F9-E293-42F1-BA67-7F68C3F3C243} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {156116FF-243E-45E8-8717-DB72E95F56AF} diff --git a/Tests/Resgrid.Tests/Allocations/IdentifierAllocationTests.cs b/Tests/Resgrid.Tests/Allocations/IdentifierAllocationTests.cs new file mode 100644 index 000000000..44d83a09d --- /dev/null +++ b/Tests/Resgrid.Tests/Allocations/IdentifierAllocationTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using FluentAssertions; +using FluentMigrator; +using NUnit.Framework; +using Resgrid.Model; + +namespace Resgrid.Tests.Allocations +{ + /// + /// CI enforcement for the cross-project identifier allocation registry + /// (../int-Coordination/docs/architecture/identifier-allocation-registry.md, section 5). + /// These tests catch the exact defect class the registry exists to prevent: a duplicated + /// append-only identifier, or the two migration dialects drifting apart. + /// + [TestFixture] + public class IdentifierAllocationTests + { + #region Registry test 1 — no duplicate values in append-only enums + + [TestCase(typeof(PermissionTypes))] + [TestCase(typeof(WorkflowTriggerEventType))] + [TestCase(typeof(DepartmentSettingTypes))] + [TestCase(typeof(Resgrid.Model.Events.EventTypes))] + public void Append_only_enum_has_no_duplicate_values(Type enumType) + { + var values = Enum.GetValues(enumType).Cast().Select(Convert.ToInt64).ToList(); + + values.Should().OnlyHaveUniqueItems( + $"{enumType.Name} is an append-only registry sequence; a duplicated value silently merges two features' identifiers"); + } + + #endregion + + #region Registry test 3 — migration numbers unique per dialect, and the two sets identical + + private static Dictionary> MigrationsOf(System.Reflection.Assembly assembly) + { + return assembly.GetTypes() + .Select(t => new { Type = t, Attribute = t.GetCustomAttributes(typeof(MigrationAttribute), false).Cast().FirstOrDefault() }) + .Where(x => x.Attribute != null) + .GroupBy(x => x.Attribute.Version) + .ToDictionary(g => g.Key, g => g.Select(x => x.Type.Name).OrderBy(n => n).ToList()); + } + + [Test] + public void Migration_numbers_are_unique_in_each_dialect_and_the_two_sets_are_identical() + { + var sqlServer = MigrationsOf(typeof(Resgrid.Providers.Migrations.Migrations.M0001_InitialMigration).Assembly); + var postgres = MigrationsOf(typeof(Resgrid.Providers.MigrationsPg.Migrations.M0001_InitialMigrationPg).Assembly); + + sqlServer.Where(kv => kv.Value.Count > 1).Should().BeEmpty( + "a migration number registered twice in the SQL Server project runs in undefined order"); + postgres.Where(kv => kv.Value.Count > 1).Should().BeEmpty( + "a migration number registered twice in the PostgreSQL project runs in undefined order"); + + sqlServer.Keys.Except(postgres.Keys).Should().BeEmpty( + "every SQL Server migration needs its Pg twin (a deliberate no-op still ships as a numbered twin)"); + postgres.Keys.Except(sqlServer.Keys).Should().BeEmpty( + "every PostgreSQL migration needs its SQL Server twin"); + } + + [Test] + public void Migration_filename_style_numbers_match_their_attributes() + { + foreach (var assembly in new[] + { + typeof(Resgrid.Providers.Migrations.Migrations.M0001_InitialMigration).Assembly, + typeof(Resgrid.Providers.MigrationsPg.Migrations.M0001_InitialMigrationPg).Assembly + }) + { + var mismatches = assembly.GetTypes() + .Select(t => new { Type = t, Attribute = t.GetCustomAttributes(typeof(MigrationAttribute), false).Cast().FirstOrDefault() }) + .Where(x => x.Attribute != null) + .Select(x => new + { + x.Type.Name, + x.Attribute.Version, + NameNumber = Regex.Match(x.Type.Name, @"^M(\d{4})_") is { Success: true } m + ? long.Parse(m.Groups[1].Value) + : -1 + }) + .Where(x => x.NameNumber != x.Version) + .ToList(); + + mismatches.Should().BeEmpty( + "the M#### class-name prefix is how humans allocate numbers; it must equal the [Migration] attribute"); + } + } + + #endregion + + #region Registry test 4 — no worker command ID registered twice + + [Test] + public void Worker_command_ids_are_registered_once() + { + var programPath = FindRepositoryFile(Path.Combine("Workers", "Resgrid.Workers.Console", "Program.cs")); + if (programPath == null) + { + Assert.Inconclusive("Workers.Console Program.cs not found relative to the test assembly; source-scan check skipped."); + return; + } + + // Commented-out registrations don't collide; scan active lines only. + var source = string.Join("\n", System.IO.File.ReadAllLines(programPath) + .Where(line => !line.TrimStart().StartsWith("//", StringComparison.Ordinal))); + + // Worker command IDs are the integer ctor argument of each scheduled/published command: + // new Commands.FooCommand(27). One ID may appear for multiple schedule lines of the SAME + // command; two DIFFERENT commands on one ID is the defect. + var idsToCommands = new Dictionary>(); + foreach (Match match in Regex.Matches(source, @"new\s+(?:Commands\.)?(\w+Command)\((\d+)\)")) + { + var id = int.Parse(match.Groups[2].Value); + if (!idsToCommands.TryGetValue(id, out var commands)) + idsToCommands[id] = commands = new HashSet(StringComparer.Ordinal); + commands.Add(match.Groups[1].Value); + } + + idsToCommands.Should().NotBeEmpty("the scan must actually find command registrations"); + idsToCommands.Where(kv => kv.Value.Count > 1).Should().BeEmpty( + "two different worker commands sharing one ID collide in the Quidjibo schedule"); + } + + private static string FindRepositoryFile(string relativePath) + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (System.IO.File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + return null; + } + + #endregion + } +} diff --git a/Tests/Resgrid.Tests/Bootstrapper.cs b/Tests/Resgrid.Tests/Bootstrapper.cs index f58e3c3a4..85baa7468 100644 --- a/Tests/Resgrid.Tests/Bootstrapper.cs +++ b/Tests/Resgrid.Tests/Bootstrapper.cs @@ -66,6 +66,30 @@ public static void Initialize() builder.RegisterInstance(new Moq.Mock().Object) .As(); + // ADP repositories are not part of the testing data module; loose mocks keep the + // protection/projection/lock services resolvable. Un-setup members return null, which + // reads as "no policy row" = Disabled — every safe view is then the original value. + builder.RegisterInstance(new Moq.Mock().Object) + .As(); + builder.RegisterInstance(new Moq.Mock().Object) + .As(); + builder.RegisterInstance(new Moq.Mock().Object) + .As(); + builder.RegisterInstance(new Moq.Mock().Object) + .As(); + builder.RegisterInstance(new Moq.Mock().Object) + .As(); + builder.RegisterInstance(new Moq.Mock().Object) + .As(); + builder.RegisterInstance(new Moq.Mock().Object) + .As(); + + // The real FeatureToggleService's repository graph is not in the testing data module; + // the protection service consumes it only for the enrollment admission gate, which no + // container-driven test exercises. Loose mock: every flag reads as absent (fail closed). + builder.RegisterInstance(new Moq.Mock().Object) + .As(); + // UDF mock repositories builder.RegisterType() .As() diff --git a/Tests/Resgrid.Tests/Providers/OpenBaoTransitKeyWrappingProviderTests.cs b/Tests/Resgrid.Tests/Providers/OpenBaoTransitKeyWrappingProviderTests.cs new file mode 100644 index 000000000..c11e273fd --- /dev/null +++ b/Tests/Resgrid.Tests/Providers/OpenBaoTransitKeyWrappingProviderTests.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Providers.ProtectedData; + +namespace Resgrid.Tests.Providers +{ + [TestFixture] + public class OpenBaoTransitKeyWrappingProviderTests + { + private string _originalAddress; + private string _originalMount; + private string _originalKeyName; + + [SetUp] + public void SetUp() + { + _originalAddress = DataProtectionConfig.OpenBaoAddress; + _originalMount = DataProtectionConfig.OpenBaoTransitMount; + _originalKeyName = DataProtectionConfig.OpenBaoTransitKeyName; + + DataProtectionConfig.OpenBaoAddress = "https://bao.test:8200"; + DataProtectionConfig.OpenBaoTransitMount = "transit"; + DataProtectionConfig.OpenBaoTransitKeyName = "resgrid-dept-kek"; + } + + [TearDown] + public void TearDown() + { + DataProtectionConfig.OpenBaoAddress = _originalAddress; + DataProtectionConfig.OpenBaoTransitMount = _originalMount; + DataProtectionConfig.OpenBaoTransitKeyName = _originalKeyName; + } + + private sealed class ScriptedHandler : HttpMessageHandler + { + private readonly Queue> _responses = new(); + public List<(string Path, string Body, string Token)> Requests { get; } = new(); + + public ScriptedHandler Enqueue(HttpStatusCode status, string json) + { + _responses.Enqueue(_ => new HttpResponseMessage(status) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + return this; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content == null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + request.Headers.TryGetValues("X-Vault-Token", out var tokens); + Requests.Add((request.RequestUri.AbsolutePath, body, tokens?.FirstOrDefault())); + + if (_responses.Count == 0) + throw new InvalidOperationException("No scripted response left for " + request.RequestUri); + + return _responses.Dequeue()(request); + } + } + + private static string LoginJson(string token = "test-token", int lease = 3600) => + new JObject { ["auth"] = new JObject { ["client_token"] = token, ["lease_duration"] = lease } }.ToString(); + + [Test] + public async Task Datakey_request_carries_department_context_and_token() + { + var handler = new ScriptedHandler() + .Enqueue(HttpStatusCode.OK, LoginJson()) + .Enqueue(HttpStatusCode.OK, new JObject + { + ["data"] = new JObject { ["ciphertext"] = "vault:v3:wrapped-blob", ["key_version"] = 3 } + }.ToString()); + + using var provider = new OpenBaoTransitKeyWrappingProvider(handler); + var wrapped = await provider.GenerateWrappedDataKeyAsync(42); + + wrapped.WrappedKeyBase64.Should().Be("vault:v3:wrapped-blob"); + wrapped.ProviderType.Should().Be("OpenBaoTransit"); + wrapped.ProviderKeyReference.Should().Be("transit/resgrid-dept-kek"); + wrapped.ProviderKeyVersion.Should().Be(3); + + handler.Requests.Should().HaveCount(2); + handler.Requests[0].Path.Should().Be("/v1/auth/cert/login"); + handler.Requests[1].Path.Should().Be("/v1/transit/datakey/wrapped/resgrid-dept-kek"); + handler.Requests[1].Token.Should().Be("test-token"); + + var body = JObject.Parse(handler.Requests[1].Body); + body["context"].Value().Should().Be(Convert.ToBase64String(Encoding.UTF8.GetBytes("42")), + "the derived-key context is base64(DepartmentId) per the A.8 ceremony"); + body["bits"].Value().Should().Be(256); + } + + [Test] + public async Task Unwrap_round_trips_plaintext_into_bytes_and_reuses_the_token() + { + var dek = new byte[32]; + RandomNumberGenerator.Fill(dek); + + var handler = new ScriptedHandler() + .Enqueue(HttpStatusCode.OK, LoginJson()) + .Enqueue(HttpStatusCode.OK, new JObject + { + ["data"] = new JObject { ["plaintext"] = Convert.ToBase64String(dek) } + }.ToString()) + .Enqueue(HttpStatusCode.OK, new JObject + { + ["data"] = new JObject { ["plaintext"] = Convert.ToBase64String(dek) } + }.ToString()); + + using var provider = new OpenBaoTransitKeyWrappingProvider(handler); + var first = await provider.UnwrapDataKeyAsync(42, "vault:v3:wrapped-blob"); + var second = await provider.UnwrapDataKeyAsync(42, "vault:v3:wrapped-blob"); + + first.Should().Equal(dek); + second.Should().Equal(dek); + + // One login, two decrypts — the lease-fresh token is reused, never re-fetched per call. + handler.Requests.Count(r => r.Path == "/v1/auth/cert/login").Should().Be(1); + handler.Requests.Count(r => r.Path == "/v1/transit/decrypt/resgrid-dept-kek").Should().Be(2); + + var body = JObject.Parse(handler.Requests[1].Body); + body["ciphertext"].Value().Should().Be("vault:v3:wrapped-blob"); + body["context"].Value().Should().Be(Convert.ToBase64String(Encoding.UTF8.GetBytes("42"))); + } + + [Test] + public async Task Failed_operation_fails_closed() + { + var handler = new ScriptedHandler() + .Enqueue(HttpStatusCode.OK, LoginJson()) + .Enqueue(HttpStatusCode.Forbidden, "{\"errors\":[\"permission denied\"]}"); + + using var provider = new OpenBaoTransitKeyWrappingProvider(handler); + + var act = async () => await provider.UnwrapDataKeyAsync(42, "vault:v3:wrapped-blob"); + await act.Should().ThrowAsync(); + } + + [Test] + public async Task Failed_login_fails_closed_before_any_transit_call() + { + var handler = new ScriptedHandler() + .Enqueue(HttpStatusCode.Forbidden, "{\"errors\":[\"invalid certificate\"]}"); + + using var provider = new OpenBaoTransitKeyWrappingProvider(handler); + + var act = async () => await provider.GenerateWrappedDataKeyAsync(42); + await act.Should().ThrowAsync(); + handler.Requests.Should().HaveCount(1, "no transit call may be attempted without a token"); + } + + [Test] + public async Task Malformed_success_response_fails_closed() + { + var handler = new ScriptedHandler() + .Enqueue(HttpStatusCode.OK, LoginJson()) + .Enqueue(HttpStatusCode.OK, "{\"data\":{}}"); + + using var provider = new OpenBaoTransitKeyWrappingProvider(handler); + + var act = async () => await provider.GenerateWrappedDataKeyAsync(42); + await act.Should().ThrowAsync(); + } + + [Test] + public void Missing_address_refuses_to_construct() + { + DataProtectionConfig.OpenBaoAddress = ""; + + var act = () => new OpenBaoTransitKeyWrappingProvider(new ScriptedHandler()); + act.Should().Throw(); + } + + [Test] + public void Plaintext_http_address_refuses_to_construct() + { + // An http endpoint would carry the cert-auth token and unwrapped DEKs unencrypted + // (plan A.14 prohibited configurations). + DataProtectionConfig.OpenBaoAddress = "http://bao.test:8200"; + + var act = () => new OpenBaoTransitKeyWrappingProvider(new ScriptedHandler()); + act.Should().Throw().WithMessage("*HTTPS*"); + } + } +} diff --git a/Tests/Resgrid.Tests/Providers/ProtectedDataBrokerClientTests.cs b/Tests/Resgrid.Tests/Providers/ProtectedDataBrokerClientTests.cs new file mode 100644 index 000000000..fb5b0f320 --- /dev/null +++ b/Tests/Resgrid.Tests/Providers/ProtectedDataBrokerClientTests.cs @@ -0,0 +1,81 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Providers.ProtectedData; + +namespace Resgrid.Tests.Providers +{ + /// + /// App-tier broker client transport rules: HTTPS only (the request carries the workload key, + /// the caller's grant and protected field values), and every configuration fault reads as + /// "broker unavailable" — fail closed, never a partial fallback. + /// + [TestFixture] + public class ProtectedDataBrokerClientTests + { + private string _originalBaseUrl; + + [SetUp] + public void SetUp() + { + _originalBaseUrl = DataProtectionConfig.BrokerBaseUrl; + } + + [TearDown] + public void TearDown() + { + DataProtectionConfig.BrokerBaseUrl = _originalBaseUrl; + } + + private sealed class RefusingHandler : HttpMessageHandler + { + public int Requests; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests++; + throw new HttpRequestException("no network in tests"); + } + } + + [Test] + public async Task Plaintext_http_broker_url_reads_as_unconfigured_and_never_sends() + { + DataProtectionConfig.BrokerBaseUrl = "http://broker.test:8080"; + var handler = new RefusingHandler(); + using var client = new ProtectedDataBrokerClient(handler); + + client.IsConfigured.Should().BeFalse(); + (await client.IsHealthyAsync()).Should().BeFalse(); + + var result = await client.DecryptAsync(42, "grant", "req-1", + new[] { new Resgrid.Model.Providers.ProtectedFieldOperationItem { FieldId = "f", RowKey = "1", Value = "rgdp:1:1:AAAA" } }); + + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be("broker_unavailable"); + handler.Requests.Should().Be(0, "no byte may leave the host toward a plaintext broker endpoint"); + } + + [Test] + public void Https_broker_url_is_configured() + { + DataProtectionConfig.BrokerBaseUrl = "https://broker.test:8443"; + using var client = new ProtectedDataBrokerClient(new RefusingHandler()); + + client.IsConfigured.Should().BeTrue(); + } + + [Test] + public async Task Empty_broker_url_reads_as_unconfigured() + { + DataProtectionConfig.BrokerBaseUrl = ""; + using var client = new ProtectedDataBrokerClient(new RefusingHandler()); + + client.IsConfigured.Should().BeFalse(); + (await client.IsHealthyAsync()).Should().BeFalse(); + } + } +} diff --git a/Tests/Resgrid.Tests/Resgrid.Tests.csproj b/Tests/Resgrid.Tests/Resgrid.Tests.csproj index 953931146..14b7bff18 100644 --- a/Tests/Resgrid.Tests/Resgrid.Tests.csproj +++ b/Tests/Resgrid.Tests/Resgrid.Tests.csproj @@ -56,6 +56,7 @@ + @@ -63,6 +64,8 @@ + + diff --git a/Tests/Resgrid.Tests/Services/AdpPermissionDefaultsTests.cs b/Tests/Resgrid.Tests/Services/AdpPermissionDefaultsTests.cs new file mode 100644 index 000000000..310b8d9b9 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/AdpPermissionDefaultsTests.cs @@ -0,0 +1,55 @@ +using System; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; + +namespace Resgrid.Tests.Services +{ + /// + /// Pins the ADP no-row permission defaults. Resgrid's convention is "missing row = allowed"; + /// protected data deliberately inverts that, and enforcement + the Security & Permissions + /// admin page both read this map — a change here changes who can reach protected data in every + /// department that never touched the settings. + /// + [TestFixture] + public class AdpPermissionDefaultsTests + { + [TestCase(PermissionTypes.ViewProtectedCallData, PermissionActions.Everyone, + TestName = "View_call_defaults_to_everyone_so_responders_can_read_a_dispatch")] + [TestCase(PermissionTypes.EditProtectedCallData, PermissionActions.Everyone, + TestName = "Edit_call_defaults_to_everyone_to_match_call_workflow")] + [TestCase(PermissionTypes.ViewProtectedOperationalData, PermissionActions.DepartmentAndGroupAdmins, + TestName = "Operational_data_defaults_to_department_and_group_admins")] + [TestCase(PermissionTypes.ManageDepartmentDataProtection, PermissionActions.DepartmentAdminsOnly, + TestName = "Settings_management_defaults_to_department_admins")] + [TestCase(PermissionTypes.ViewProtectedPersonnelData, PermissionActions.DepartmentAdminsOnly, + TestName = "Personnel_PII_defaults_to_department_admins")] + [TestCase(PermissionTypes.ViewProtectedContactData, PermissionActions.DepartmentAdminsOnly, + TestName = "Contact_PII_defaults_to_department_admins")] + [TestCase(PermissionTypes.ExportProtectedData, PermissionActions.DepartmentAdminsOnly, + TestName = "Export_defaults_to_department_admins")] + [TestCase(PermissionTypes.ConfigureProtectedDataEgress, PermissionActions.DepartmentAdminsOnly, + TestName = "Egress_configuration_defaults_to_department_admins")] + [TestCase(PermissionTypes.BreakGlassProtectedData, PermissionActions.DepartmentAdminsOnly, + TestName = "Break_glass_defaults_to_department_admins")] + public void Adp_no_row_defaults_are_pinned(PermissionTypes type, PermissionActions expected) + { + AdpPermissionDefaults.For(type).Should().Be(expected); + } + + [Test] + public void Every_adp_permission_value_has_a_default_and_nothing_else_does() + { + // 31-39 are the ADP block per the identifier allocation registry. + for (var value = 31; value <= 39; value++) + { + var act = () => AdpPermissionDefaults.For((PermissionTypes)value); + act.Should().NotThrow($"PermissionTypes value {value} is an ADP permission and needs a default"); + } + + var nonAdp = () => AdpPermissionDefaults.For(PermissionTypes.CreateCall); + nonAdp.Should().Throw( + "non-ADP permissions must use the standard wide-open evaluation, never this map"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs new file mode 100644 index 000000000..5ad970095 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class AdpSizingServiceTests + { + private int _originalThroughput; + private int _originalOverhead; + private double _originalAllowance; + private double _originalMultiplier; + + [SetUp] + public void SetUp() + { + _originalThroughput = DataProtectionConfig.MigrationBenchmarkRowsPerSecond; + _originalOverhead = DataProtectionConfig.MigrationEstimatePerTableOverheadSeconds; + _originalAllowance = DataProtectionConfig.MigrationEstimateVerificationAllowance; + _originalMultiplier = DataProtectionConfig.MigrationEstimateP90Multiplier; + } + + [TearDown] + public void TearDown() + { + DataProtectionConfig.MigrationBenchmarkRowsPerSecond = _originalThroughput; + DataProtectionConfig.MigrationEstimatePerTableOverheadSeconds = _originalOverhead; + DataProtectionConfig.MigrationEstimateVerificationAllowance = _originalAllowance; + DataProtectionConfig.MigrationEstimateP90Multiplier = _originalMultiplier; + } + + [Test] + public async Task Scan_counts_every_binding_and_derives_the_range_and_nights() + { + DataProtectionConfig.MigrationBenchmarkRowsPerSecond = 100; + DataProtectionConfig.MigrationEstimatePerTableOverheadSeconds = 30; + DataProtectionConfig.MigrationEstimateVerificationAllowance = 0.25; + DataProtectionConfig.MigrationEstimateP90Multiplier = 2.0; + + var bulk = new Mock(); + bulk.Setup(x => x.CountRowsAsync(It.IsAny(), 7, It.IsAny())).ReturnsAsync(10000); + + var service = new AdpSizingService(bulk.Object); + var result = await service.RunSizingScanAsync(7, windowMinutes: 480); + + result.TableRowCounts.Should().HaveCount(AdpTableBindings.V1.Count, + "every catalog binding is counted"); + result.TotalRows.Should().Be(10000L * AdpTableBindings.V1.Count); + + // 80,000 rows / 100 rps = 800s + 8×30s overhead = 1040s; ×1.25 = 1300s → 22 min P50. + result.EstimatedP50Minutes.Should().Be(22); + result.EstimatedP90Minutes.Should().Be(44); + result.ProjectedNights.Should().Be(1, "44 minutes fits one 480-minute window"); + result.BenchmarkRowsPerSecond.Should().Be(100); + } + + [Test] + public async Task Large_departments_project_multiple_nights_from_the_p90_estimate() + { + DataProtectionConfig.MigrationBenchmarkRowsPerSecond = 100; + DataProtectionConfig.MigrationEstimatePerTableOverheadSeconds = 0; + DataProtectionConfig.MigrationEstimateVerificationAllowance = 0; + DataProtectionConfig.MigrationEstimateP90Multiplier = 2.0; + + var bulk = new Mock(); + // Only Calls has rows: 6,000,000 rows / 100 rps = 60,000s = 1000 min P50, 2000 min P90. + bulk.Setup(x => x.CountRowsAsync(It.IsAny(), 7, It.IsAny())).ReturnsAsync(0); + bulk.Setup(x => x.CountRowsAsync(It.Is(b => b.TableName == "Calls"), 7, It.IsAny())) + .ReturnsAsync(6_000_000); + + var service = new AdpSizingService(bulk.Object); + var result = await service.RunSizingScanAsync(7, windowMinutes: 480); + + result.EstimatedP90Minutes.Should().Be(2000); + result.ProjectedNights.Should().Be(5, "ceil(2000 / 480) = 5 overnight windows"); + } + + [Test] + public async Task Empty_department_still_projects_one_night() + { + var bulk = new Mock(); + bulk.Setup(x => x.CountRowsAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(0); + + var service = new AdpSizingService(bulk.Object); + var result = await service.RunSizingScanAsync(7, windowMinutes: 480); + + result.TotalRows.Should().Be(0); + result.ProjectedNights.Should().Be(1); + result.EstimatedP90Minutes.Should().BeGreaterThanOrEqualTo(result.EstimatedP50Minutes); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs b/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs new file mode 100644 index 000000000..86166d66f --- /dev/null +++ b/Tests/Resgrid.Tests/Services/BrokerOperationServiceTests.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using FluentAssertions; +using Microsoft.Extensions.Caching.Memory; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; +using Resgrid.Web.Broker.Models; +using Resgrid.Web.Broker.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Broker field-crypto pipeline (ADP plan section 3.1 steps 8-9) against the REAL grant, crypto + /// and LocalDev key-wrapping implementations — only the repositories are mocked. Proves the + /// grant gates (invalid/revoked/scope), replay refusal, encrypt/decrypt roundtrip with AAD + /// binding, the double-encryption guard, and fail-closed item errors. + /// + [TestFixture] + public class BrokerOperationServiceTests + { + private const int DeptId = 42; + private const long Epoch = 3; + + private X509Certificate2 _certificate; + private ProtectedDataGrantService _grantService; + private LocalDevKeyWrappingProvider _keyWrappingProvider; + private ProtectedFieldCryptoService _cryptoService; + private Mock _policyRepo; + private Mock _keyService; + private IContainer _container; + private BrokerOperationService _service; + private DepartmentDataProtectionKey _activeKey; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=adp-broker-tests", ecdsa, HashAlgorithmName.SHA256); + _certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(2)); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + _certificate?.Dispose(); + _container?.Dispose(); + } + + [SetUp] + public async Task SetUp() + { + _container?.Dispose(); + + _grantService = new ProtectedDataGrantService(() => _certificate, () => _certificate); + _keyWrappingProvider = new LocalDevKeyWrappingProvider(); + _cryptoService = new ProtectedFieldCryptoService(); + + var wrapped = await _keyWrappingProvider.GenerateWrappedDataKeyAsync(DeptId); + _activeKey = new DepartmentDataProtectionKey + { + DepartmentId = DeptId, + Version = 1, + Status = (int)DepartmentDataProtectionKeyStatus.Active, + WrappedKey = wrapped.WrappedKeyBase64 + }; + + _policyRepo = new Mock(); + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)) + .ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = DeptId, PolicyEpoch = Epoch }); + + _keyService = new Mock(); + _keyService.Setup(x => x.GetActiveKeyAsync(DeptId)).ReturnsAsync(_activeKey); + _keyService.Setup(x => x.GetKeyByVersionAsync(DeptId, 1)).ReturnsAsync(_activeKey); + + var builder = new ContainerBuilder(); + builder.RegisterInstance(_policyRepo.Object).As(); + builder.RegisterInstance(_keyService.Object).As(); + _container = builder.Build(); + + _service = new BrokerOperationService(_container, _grantService, _cryptoService, + _keyWrappingProvider, new MemoryCache(new MemoryCacheOptions())); + } + + private string IssueGrantToken(params string[] scopes) + { + var issued = _grantService.IssueGrant(new ProtectedDataGrantIssueRequest + { + UserId = "user-1", + DepartmentId = DeptId, + PolicyEpoch = Epoch, + WindowMinutes = 15, + Scopes = scopes.Length > 0 ? scopes : new[] { ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write }, + MfaAtUtc = DateTime.UtcNow + }); + return issued.Token; + } + + private static BrokerFieldOperationRequest Request(string grantToken, string requestId, + params ProtectedFieldOperationItem[] items) => new BrokerFieldOperationRequest + { + DepartmentId = DeptId, + GrantToken = grantToken, + RequestId = requestId, + Items = items.ToList() + }; + + private static ProtectedFieldOperationItem Item(string value, string fieldId = "calls.natureofcall", + string rowKey = "17", int catalogVersion = 1) => new ProtectedFieldOperationItem + { + FieldId = fieldId, + RowKey = rowKey, + Value = value, + CatalogVersion = catalogVersion + }; + + [Test] + public async Task Encrypt_then_decrypt_roundtrips_with_full_aad_binding() + { + var token = IssueGrantToken(); + + var encrypted = await _service.EncryptAsync(Request(token, "req-1", Item("Structure fire, 3 Main St")), CancellationToken.None); + encrypted.Success.Should().BeTrue(); + encrypted.Items.Should().HaveCount(1); + encrypted.Items[0].ErrorCode.Should().BeNull(); + ProtectedDataEnvelope.IsEnveloped(encrypted.Items[0].Value).Should().BeTrue(); + + var decrypted = await _service.DecryptAsync(Request(token, "req-2", Item(encrypted.Items[0].Value)), CancellationToken.None); + decrypted.Success.Should().BeTrue(); + decrypted.Items[0].ErrorCode.Should().BeNull(); + decrypted.Items[0].Value.Should().Be("Structure fire, 3 Main St"); + } + + [Test] + public async Task Moved_ciphertext_fails_decrypt_per_item_without_failing_the_request() + { + var token = IssueGrantToken(); + var encrypted = await _service.EncryptAsync(Request(token, "req-1", Item("secret", rowKey: "17")), CancellationToken.None); + + // Same envelope presented for a different row: AAD mismatch. + var moved = await _service.DecryptAsync(Request(token, "req-2", + Item(encrypted.Items[0].Value, rowKey: "99")), CancellationToken.None); + + moved.Success.Should().BeTrue(); + moved.Items[0].ErrorCode.Should().Be("decrypt_failed"); + moved.Items[0].Value.Should().BeNull(); + } + + [Test] + public async Task Replayed_request_id_is_refused() + { + var token = IssueGrantToken(); + var encrypted = await _service.EncryptAsync(Request(token, "req-1", Item("value")), CancellationToken.None); + encrypted.Success.Should().BeTrue(); + + var replay = await _service.EncryptAsync(Request(token, "req-1", Item("value")), CancellationToken.None); + replay.Success.Should().BeFalse(); + replay.ErrorCode.Should().Be("replayed_request"); + replay.Items.Should().BeEmpty(); + } + + [Test] + public async Task Revoked_grant_after_epoch_bump_is_refused() + { + var token = IssueGrantToken(); + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)) + .ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = DeptId, PolicyEpoch = Epoch + 1 }); + + var result = await _service.DecryptAsync(Request(token, "req-1", Item("rgdp:1:1:AAAA")), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be("grant_revoked"); + result.Items.Should().BeEmpty(); + } + + [Test] + public async Task Grant_without_the_write_scope_cannot_encrypt() + { + var readOnlyToken = IssueGrantToken(ProtectedDataGrantScopes.Read); + + var result = await _service.EncryptAsync(Request(readOnlyToken, "req-1", Item("value")), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be("grant_invalid"); + } + + [Test] + public async Task Garbage_grant_is_refused() + { + var result = await _service.DecryptAsync(Request("not-a-grant", "req-1", Item("rgdp:1:1:AAAA")), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be("grant_invalid"); + } + + [Test] + public async Task Decrypting_plaintext_reports_not_enveloped_and_never_echoes_the_value() + { + var token = IssueGrantToken(); + + var result = await _service.DecryptAsync(Request(token, "req-1", Item("just plain text")), CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Items[0].ErrorCode.Should().Be("not_enveloped"); + result.Items[0].Value.Should().BeNull(); + } + + [Test] + public async Task Encrypting_an_envelope_trips_the_double_encryption_guard() + { + var token = IssueGrantToken(); + var encrypted = await _service.EncryptAsync(Request(token, "req-1", Item("value")), CancellationToken.None); + + var again = await _service.EncryptAsync(Request(token, "req-2", Item(encrypted.Items[0].Value)), CancellationToken.None); + + again.Success.Should().BeTrue(); + again.Items[0].ErrorCode.Should().Be("already_enveloped"); + again.Items[0].Value.Should().BeNull(); + } + + [Test] + public async Task Unknown_key_version_reports_key_unknown() + { + var token = IssueGrantToken(); + + var result = await _service.DecryptAsync(Request(token, "req-1", Item("rgdp:1:9:AAAA")), CancellationToken.None); + + result.Success.Should().BeTrue(); + result.Items[0].ErrorCode.Should().Be("key_unknown"); + } + + [Test] + public async Task Oversized_requests_are_refused() + { + var token = IssueGrantToken(); + var items = Enumerable.Range(0, Resgrid.Config.DataProtectionConfig.BrokerMaxItemsPerRequest + 1) + .Select(i => Item("value", rowKey: i.ToString())) + .ToArray(); + + var result = await _service.EncryptAsync(Request(token, "req-1", items), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be("too_many_items"); + } + + [Test] + public async Task Missing_active_key_fails_encrypt_closed() + { + var token = IssueGrantToken(); + _keyService.Setup(x => x.GetActiveKeyAsync(DeptId)).ReturnsAsync((DepartmentDataProtectionKey)null); + + var result = await _service.EncryptAsync(Request(token, "req-1", Item("value")), CancellationToken.None); + + result.Success.Should().BeFalse(); + result.ErrorCode.Should().Be("no_active_key"); + result.Items.Should().BeEmpty(); + } + + [Test] + public async Task Empty_and_null_requests_are_invalid() + { + var missingItems = await _service.DecryptAsync(new BrokerFieldOperationRequest + { + DepartmentId = DeptId, + GrantToken = "x", + RequestId = "req-1", + Items = new List() + }, CancellationToken.None); + missingItems.Success.Should().BeFalse(); + missingItems.ErrorCode.Should().Be("invalid_request"); + + var nullRequest = await _service.DecryptAsync(null, CancellationToken.None); + nullRequest.Success.Should().BeFalse(); + nullRequest.ErrorCode.Should().Be("invalid_request"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs index c0cf1ba36..aba7437ca 100644 --- a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs @@ -28,6 +28,7 @@ public class with_the_communication_service : TestBase protected Mock _userStateServiceMock; protected Mock _departmentsServiceMock; protected Mock _chatbotOutboundServiceMock; + protected Mock _protectedProjectionServiceMock; protected ICommunicationService _communicationService; // NUnit builds one fixture instance for the whole class, so mocks created in a constructor are @@ -53,10 +54,21 @@ public void SetUpCommunicationService() .ReturnsAsync(new DepartmentMember()); _chatbotOutboundServiceMock = new Mock(); + + // Pass-through by default: these tests exercise unprotected departments, where the + // notification-safe view is the original call. + _protectedProjectionServiceMock = new Mock(); + _protectedProjectionServiceMock + .Setup(x => x.BuildNotificationSafeCallAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((d, c, ch) => Task.FromResult(c)); + _protectedProjectionServiceMock + .Setup(x => x.IsChannelSanitizedAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + _communicationService = new CommunicationService(_smsServiceMock.Object, _emailServiceMock.Object, _pushServiceMock.Object, _geoLocationProviderMock.Object, _outboundVoiceProviderMock.Object, _userProfileServiceMock.Object, _departmentSettingsServiceMock.Object, _subscriptionsServiceMock.Object, _userStateServiceMock.Object, _chatbotOutboundServiceMock.Object, - _departmentsServiceMock.Object); + _departmentsServiceMock.Object, _protectedProjectionServiceMock.Object); } } diff --git a/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs b/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs new file mode 100644 index 000000000..4c46b5cd1 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs @@ -0,0 +1,459 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Engine tests over an in-memory bulk store — including the plan section 15 double-run proof: + /// encrypting twice is a no-op, decrypting twice is a no-op, plaintext on the decrypt path passes + /// through counted as an anomaly, and a foreign-AAD envelope halts the run and is never + /// re-encrypted. + /// + [TestFixture] + public class DepartmentDataMigrationEngineTests + { + private const int DeptId = 42; + + private InMemoryBulkRepository _bulk; + private InMemoryMigrationRepository _migrations; + private Mock _keyService; + private LocalDevKeyWrappingProvider _keyProvider; + private ProtectedFieldCryptoService _crypto; + private DepartmentDataMigrationEngine _engine; + private byte[] _dek; + + [SetUp] + public async Task SetUp() + { + _bulk = new InMemoryBulkRepository(); + _migrations = new InMemoryMigrationRepository(); + _bulk.MigrationRows = _migrations.Rows; + _keyProvider = new LocalDevKeyWrappingProvider(); + _crypto = new ProtectedFieldCryptoService(); + + var wrapped = await _keyProvider.GenerateWrappedDataKeyAsync(DeptId); + var keyRow = new DepartmentDataProtectionKey + { + DepartmentId = DeptId, + Version = 1, + WrappedKey = wrapped.WrappedKeyBase64, + Status = (int)DepartmentDataProtectionKeyStatus.Active + }; + _dek = await _keyProvider.UnwrapDataKeyAsync(DeptId, wrapped.WrappedKeyBase64); + + _keyService = new Mock(); + _keyService.Setup(x => x.GetKeyByVersionAsync(DeptId, 1)).ReturnsAsync(keyRow); + _keyService.Setup(x => x.GetActiveKeyAsync(DeptId)).ReturnsAsync(keyRow); + + _engine = new DepartmentDataMigrationEngine(_bulk, _migrations, _keyService.Object, _keyProvider, _crypto); + + // Three call rows: one rich, one sparse, one with empty strings only. + _bulk.Seed("Calls", "CallId", + Row(("CallId", 1), ("Name", "Structure Fire"), ("NatureOfCall", "Smoke showing"), ("Notes", "Occupant on O2")), + Row(("CallId", 2), ("Name", "MVA"), ("NatureOfCall", null), ("Notes", null)), + Row(("CallId", 3), ("Name", ""), ("NatureOfCall", null), ("Notes", null))); + } + + [TearDown] + public void TearDown() + { + System.Security.Cryptography.CryptographicOperations.ZeroMemory(_dek); + } + + private static Dictionary Row(params (string Key, object Value)[] values) => + values.ToDictionary(v => v.Key, v => v.Value); + + private static AdpMigrationNightContext Context(DepartmentDataProtectionMigrationKind kind, + DateTime? windowEndUtc = null) => new AdpMigrationNightContext + { + DepartmentId = DeptId, + Kind = kind, + CatalogVersion = 1, + TargetKeyVersion = 1, + WindowEndUtc = windowEndUtc ?? DateTime.UtcNow.AddHours(1), + CorrelationId = "test-run" + }; + + [Test] + public async Task Enrollment_envelopes_every_populated_text_value_and_persists_the_cursor() + { + var result = await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + + result.Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + + var calls = _bulk.Table("Calls"); + ((string)calls[0]["Name"]).Should().StartWith("rgdp:1:1:"); + ((string)calls[0]["NatureOfCall"]).Should().StartWith("rgdp:1:1:"); + ((string)calls[0]["Notes"]).Should().StartWith("rgdp:1:1:"); + ((string)calls[1]["Name"]).Should().StartWith("rgdp:1:1:"); + calls[1]["NatureOfCall"].Should().BeNull(); + ((string)calls[2]["Name"]).Should().Be("", "empty strings are skipped, not encrypted"); + + var migrationRow = _migrations.Rows.Values.Single(r => r.TargetTable == "Calls"); + migrationRow.Cursor.Should().Be("3"); + migrationRow.RowsProcessed.Should().Be(2); + + _crypto.DecryptText(_dek, (string)calls[0]["NatureOfCall"], DeptId, "calls.natureofcall", "1", 1) + .Should().Be("Smoke showing"); + } + + [Test] + public async Task Double_run_proof_resuming_an_open_run_skips_the_completed_range() + { + await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + var snapshot = _bulk.Snapshot("Calls"); + + // Same open run re-executed (worker crash after the last batch): the durable cursor makes + // the resume a pure no-op — nothing is re-read, nothing is re-encrypted. + var second = await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + + second.Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + _bulk.Snapshot("Calls").Should().BeEquivalentTo(snapshot, "re-running a completed range must be byte-identical"); + + var migrationRow = _migrations.Rows.Values.Single(r => r.TargetTable == "Calls"); + migrationRow.RowsProcessed.Should().Be(2, "the resume must not re-encrypt anything"); + } + + [Test] + public async Task Double_run_proof_a_fresh_run_over_encrypted_data_counts_already_protected_and_changes_nothing() + { + await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + await _engine.VerifyAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + var snapshot = _bulk.Snapshot("Calls"); + + // A brand-new run (fresh migration rows, null cursor) rescans every row: the + // double-encryption guard validates each envelope against this department's AAD, counts it + // already-protected, and escalates nothing. + var second = await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + + second.Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + _bulk.Snapshot("Calls").Should().BeEquivalentTo(snapshot, "zero re-encryptions; envelopes stay byte-identical"); + + var freshRow = _migrations.Rows.Values.Single(r => r.TargetTable == "Calls" && r.CompletedOn == null); + freshRow.RowsProcessed.Should().Be(0, "no row may be re-encrypted"); + freshRow.RowsAlreadyProtected.Should().Be(2, "already-protected rows are counted, not errored"); + } + + [Test] + public async Task Fresh_run_after_a_reprovision_validates_old_version_envelopes_with_their_own_key() + { + // Enrollment encrypted everything under key v1; a failed-run recovery then minted key v2. + // The new run's double-encryption guard must validate v1 envelopes with the v1 DEK — with + // the v2 DEK every such row would read as a foreign envelope and no retry could clear it. + await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + await _engine.VerifyAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + var snapshot = _bulk.Snapshot("Calls"); + + var wrappedV2 = await _keyProvider.GenerateWrappedDataKeyAsync(DeptId); + var keyRowV2 = new DepartmentDataProtectionKey + { + DepartmentId = DeptId, + Version = 2, + WrappedKey = wrappedV2.WrappedKeyBase64, + Status = (int)DepartmentDataProtectionKeyStatus.Active + }; + _keyService.Setup(x => x.GetKeyByVersionAsync(DeptId, 2)).ReturnsAsync(keyRowV2); + _keyService.Setup(x => x.GetActiveKeyAsync(DeptId)).ReturnsAsync(keyRowV2); + + var context = Context(DepartmentDataProtectionMigrationKind.Enrollment); + context.TargetKeyVersion = 2; + var result = await _engine.RunEncryptionNightAsync(context, CancellationToken.None); + + result.Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables, + "v1 envelopes validate with the v1 DEK instead of halting as foreign"); + _bulk.Snapshot("Calls").Should().BeEquivalentTo(snapshot, + "old-version envelopes are counted already-protected, never re-encrypted"); + } + + [Test] + public async Task Envelope_referencing_an_unknown_key_version_halts_the_run() + { + _bulk.Table("Calls")[0]["Name"] = "rgdp:1:9:AAAA"; + + var result = await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + + result.Outcome.Should().Be(AdpMigrationNightOutcome.Failed); + result.ErrorCode.Should().Be("foreign_envelope"); + _bulk.Table("Calls")[0]["Name"].Should().Be("rgdp:1:9:AAAA", "an unresolvable envelope is halted on, never re-encrypted"); + } + + [Test] + public async Task Offboarding_restores_plaintext_and_second_decrypt_pass_counts_anomalies_only() + { + await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + + var decrypt = await _engine.RunDecryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Offboarding), CancellationToken.None); + decrypt.Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + + var calls = _bulk.Table("Calls"); + calls[0]["Name"].Should().Be("Structure Fire"); + calls[0]["NatureOfCall"].Should().Be("Smoke showing"); + calls[0]["Notes"].Should().Be("Occupant on O2"); + + // Complete the first offboarding run, then start a FRESH one over now-plaintext data: the + // decrypt path passes plaintext through untouched and only increments the anomaly counter. + await _engine.VerifyAsync(Context(DepartmentDataProtectionMigrationKind.Offboarding), CancellationToken.None); + var snapshot = _bulk.Snapshot("Calls"); + var secondDecrypt = await _engine.RunDecryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Offboarding), CancellationToken.None); + + secondDecrypt.Outcome.Should().Be(AdpMigrationNightOutcome.CompletedAllTables); + _bulk.Snapshot("Calls").Should().BeEquivalentTo(snapshot, "plaintext passes through the decrypt path untouched"); + + var freshOffboardingRow = _migrations.Rows.Values.Single(r => + r.TargetTable == "Calls" && r.Kind == (int)DepartmentDataProtectionMigrationKind.Offboarding && r.CompletedOn == null); + freshOffboardingRow.RowsProcessed.Should().Be(0, "nothing may be 'decrypted' into garbage"); + freshOffboardingRow.RowsAnomalous.Should().BeGreaterThan(0, "plaintext on the decrypt path increments the anomaly counter"); + } + + [Test] + public async Task Foreign_envelope_halts_the_run_and_is_never_re_encrypted() + { + // An envelope bound to another department's AAD, planted in this department's data. + var foreign = _crypto.EncryptText(_dek, 1, "someone else's data", 43, "calls.name", "1", 1); + _bulk.Table("Calls")[0]["Name"] = foreign; + + var result = await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + + result.Outcome.Should().Be(AdpMigrationNightOutcome.Failed); + result.ErrorCode.Should().Be("foreign_envelope"); + _bulk.Table("Calls")[0]["Name"].Should().Be(foreign, "a foreign envelope is halted on, never re-encrypted"); + + _migrations.Rows.Values.Single(r => r.TargetTable == "Calls").LastErrorCode.Should().Be("foreign_envelope"); + } + + [Test] + public async Task Closed_window_checkpoints_before_touching_any_row() + { + var snapshot = _bulk.Snapshot("Calls"); + + var result = await _engine.RunEncryptionNightAsync( + Context(DepartmentDataProtectionMigrationKind.Enrollment, windowEndUtc: DateTime.UtcNow.AddMinutes(-1)), + CancellationToken.None); + + result.Outcome.Should().Be(AdpMigrationNightOutcome.WindowClosed); + _bulk.Snapshot("Calls").Should().BeEquivalentTo(snapshot); + } + + [Test] + public async Task Missing_kms_fails_the_run_closed() + { + var engine = new DepartmentDataMigrationEngine(_bulk, _migrations, _keyService.Object, + new NotConfiguredKeyWrappingProvider(), _crypto); + + var result = await engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + + result.Outcome.Should().Be(AdpMigrationNightOutcome.Failed); + result.ErrorCode.Should().Be("kms_unavailable"); + } + + [Test] + public async Task Verification_gates_on_residue_in_both_directions() + { + // Plaintext residue before enrollment ran -> verification must fail. + (await _engine.VerifyAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None)) + .Should().BeFalse("plaintext residue must block Enabled"); + + await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); + (await _engine.VerifyAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None)) + .Should().BeTrue("a clean plaintext-residue scan passes enrollment verification"); + + _migrations.Rows.Values.Where(r => r.Kind == (int)DepartmentDataProtectionMigrationKind.Enrollment) + .Should().OnlyContain(r => r.CompletedOn != null && r.VerificationState == (int)DepartmentDataProtectionVerificationState.Passed); + + // Envelope residue before offboarding ran -> verification must fail; clean after. + (await _engine.VerifyAsync(Context(DepartmentDataProtectionMigrationKind.Offboarding), CancellationToken.None)) + .Should().BeFalse("envelope residue must block Disabled"); + + await _engine.RunDecryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Offboarding), CancellationToken.None); + (await _engine.VerifyAsync(Context(DepartmentDataProtectionMigrationKind.Offboarding), CancellationToken.None)) + .Should().BeTrue(); + } + + #region In-memory fakes + + private sealed class InMemoryBulkRepository : IDepartmentDataProtectionBulkRepository + { + private readonly Dictionary> Rows)> _tables = + new(StringComparer.OrdinalIgnoreCase); + + public Dictionary MigrationRows { get; set; } + + public void Seed(string table, string pkColumn, params Dictionary[] rows) => + _tables[table] = (pkColumn, rows.ToList()); + + public List> Table(string table) => _tables[table].Rows; + + public List> Snapshot(string table) => + _tables[table].Rows.Select(r => new Dictionary(r)).ToList(); + + public Task CountRowsAsync(AdpTableBinding binding, int departmentId, CancellationToken cancellationToken = default) => + Task.FromResult(_tables.TryGetValue(binding.TableName, out var t) ? t.Rows.Count : 0); + + public Task> GetBatchAsync(AdpTableBinding binding, int departmentId, + string afterCursor, int batchSize, CancellationToken cancellationToken = default) + { + if (!_tables.TryGetValue(binding.TableName, out var table)) + return Task.FromResult>(Array.Empty()); + + var ordered = table.Rows.OrderBy(r => Convert.ToInt64(r[table.PkColumn], CultureInfo.InvariantCulture)).ToList(); + var filtered = string.IsNullOrEmpty(afterCursor) + ? ordered + : ordered.Where(r => Convert.ToInt64(r[table.PkColumn], CultureInfo.InvariantCulture) > + long.Parse(afterCursor, CultureInfo.InvariantCulture)).ToList(); + + var batch = filtered.Take(batchSize).Select(r => new AdpBulkFieldRow + { + RowKey = Convert.ToString(r[table.PkColumn], CultureInfo.InvariantCulture), + Values = binding.Columns + .SelectMany(c => c.StorageKind == ProtectedFieldStorageKind.CompanionColumn + ? new[] { c.ColumnName, c.CompanionColumn } + : new[] { c.ColumnName }) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToDictionary(c => c, c => r.TryGetValue(c, out var v) ? v : null) + }).ToList(); + + return Task.FromResult>(batch); + } + + public Task ApplyBatchAsync(AdpTableBinding binding, IReadOnlyList updates, + int departmentDataProtectionMigrationId, string newCursor, long rowsProcessedDelta, + long rowsAlreadyProtectedDelta, long rowsAnomalousDelta, CancellationToken cancellationToken) + { + var table = _tables[binding.TableName]; + foreach (var update in updates ?? (IReadOnlyList)Array.Empty()) + { + var row = table.Rows.Single(r => + Convert.ToString(r[table.PkColumn], CultureInfo.InvariantCulture) == update.RowKey); + foreach (var kv in update.SetValues) + row[kv.Key] = kv.Value; + } + + var migrationRow = MigrationRows[departmentDataProtectionMigrationId]; + migrationRow.Cursor = newCursor; + migrationRow.RowsProcessed += rowsProcessedDelta; + migrationRow.RowsAlreadyProtected += rowsAlreadyProtectedDelta; + migrationRow.RowsAnomalous += rowsAnomalousDelta; + migrationRow.CheckpointedOn = DateTime.UtcNow; + return Task.CompletedTask; + } + + public Task CountTextResidueAsync(AdpTableBinding binding, int departmentId, bool enveloped, CancellationToken cancellationToken = default) + { + if (!_tables.TryGetValue(binding.TableName, out var table)) + return Task.FromResult(0L); + + var textColumns = binding.Columns.Where(c => c.StorageKind == ProtectedFieldStorageKind.Text).ToList(); + var count = table.Rows.Count(r => textColumns.Any(c => + { + var value = r.TryGetValue(c.ColumnName, out var v) ? v as string : null; + if (string.IsNullOrEmpty(value)) + return false; + var isEnvelope = value.StartsWith("rgdp:", StringComparison.Ordinal); + return enveloped ? isEnvelope : !isEnvelope; + })); + return Task.FromResult((long)count); + } + + public Task CountBinaryResidueAsync(AdpTableBinding binding, int departmentId, bool enveloped, CancellationToken cancellationToken = default) + { + if (!_tables.TryGetValue(binding.TableName, out var table)) + return Task.FromResult(0L); + + var binaryColumns = binding.Columns.Where(c => c.StorageKind == ProtectedFieldStorageKind.Binary).ToList(); + var prefix = Encoding.ASCII.GetBytes("rgdpb:"); + var count = table.Rows.Count(r => binaryColumns.Any(c => + { + var value = r.TryGetValue(c.ColumnName, out var v) ? v as byte[] : null; + if (value == null || value.Length == 0) + return false; + var isEnvelope = value.Length >= prefix.Length && prefix.SequenceEqual(value.Take(prefix.Length)); + return enveloped ? isEnvelope : !isEnvelope; + })); + return Task.FromResult((long)count); + } + + public Task CountCompanionResidueAsync(AdpTableBinding binding, int departmentId, bool enveloped, CancellationToken cancellationToken = default) + { + if (!_tables.TryGetValue(binding.TableName, out var table)) + return Task.FromResult(0L); + + var companionColumns = binding.Columns.Where(c => c.StorageKind == ProtectedFieldStorageKind.CompanionColumn).ToList(); + var count = table.Rows.Count(r => companionColumns.Any(c => + { + var column = enveloped ? c.CompanionColumn : c.ColumnName; + return r.TryGetValue(column, out var v) && v != null; + })); + return Task.FromResult((long)count); + } + } + + private sealed class InMemoryMigrationRepository : IDepartmentDataProtectionMigrationRepository + { + private int _nextId = 1; + public Dictionary Rows { get; } = new(); + + public Task> GetActiveByDepartmentIdAsync(int departmentId, + DepartmentDataProtectionMigrationKind kind) => + Task.FromResult>(Rows.Values + .Where(r => r.DepartmentId == departmentId && r.Kind == (int)kind && r.CompletedOn == null).ToList()); + + public Task GetActiveByDepartmentAndTableAsync(int departmentId, + DepartmentDataProtectionMigrationKind kind, string targetTable) => + Task.FromResult(Rows.Values.FirstOrDefault(r => r.DepartmentId == departmentId && + r.Kind == (int)kind && r.TargetTable == targetTable && r.CompletedOn == null)); + + public Task InsertAsync(DepartmentDataProtectionMigration entity, + CancellationToken cancellationToken, bool firstLevelOnly = false) + { + entity.DepartmentDataProtectionMigrationId = _nextId++; + Rows[entity.DepartmentDataProtectionMigrationId] = entity; + return Task.FromResult(entity); + } + + public Task SaveOrUpdateAsync(DepartmentDataProtectionMigration entity, + CancellationToken cancellationToken, bool firstLevelOnly = false) + { + Rows[entity.DepartmentDataProtectionMigrationId] = entity; + return Task.FromResult(entity); + } + + public Task> GetAllAsync() => + Task.FromResult>(Rows.Values.ToList()); + + public Task GetByIdAsync(object id) => + Task.FromResult(Rows.TryGetValue((int)id, out var row) ? row : null); + + public Task> GetAllByDepartmentIdAsync(int departmentId) => + Task.FromResult>( + Rows.Values.Where(r => r.DepartmentId == departmentId).ToList()); + + public Task UpdateAsync(DepartmentDataProtectionMigration entity, + CancellationToken cancellationToken, bool firstLevelOnly = false) => + SaveOrUpdateAsync(entity, cancellationToken, firstLevelOnly); + + public Task DeleteAsync(DepartmentDataProtectionMigration entity, CancellationToken cancellationToken) => + Task.FromResult(Rows.Remove(entity.DepartmentDataProtectionMigrationId)); + + public Task> GetAllByUserIdAsync(string userId) => + throw new NotSupportedException(); + + public Task DeleteMultipleAsync(DepartmentDataProtectionMigration entity, string parentKeyName, + object parentKeyId, List ids, CancellationToken cancellationToken) => + throw new NotSupportedException(); + } + + #endregion + } +} diff --git a/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs b/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs new file mode 100644 index 000000000..cf4aa2181 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs @@ -0,0 +1,415 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class DepartmentDataProtectionServiceTests + { + private const int DeptId = 7; + private const string ManagingUserId = "managing-user"; + + private Mock _policyRepo; + private Mock _egressRepo; + private Mock _departmentsService; + private Mock _featureToggleService; + private Mock _subscriptionsService; + private Mock _cacheProvider; + private DepartmentDataProtectionService _service; + + [SetUp] + public void SetUp() + { + _policyRepo = new Mock(); + _egressRepo = new Mock(); + _departmentsService = new Mock(); + _featureToggleService = new Mock(); + _subscriptionsService = new Mock(); + _cacheProvider = new Mock(); + + // Cache pass-throughs so repository setups drive behavior. + _cacheProvider + .Setup(x => x.RetrieveAsync(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((key, fallback, expiration) => fallback()); + _cacheProvider + .Setup(x => x.RetrieveAsync(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((key, fallback, expiration) => fallback()); + _cacheProvider.Setup(x => x.RemoveAsync(It.IsAny())).ReturnsAsync(true); + + // Happy-path defaults; individual tests override to exercise each denial. + _departmentsService.Setup(x => x.GetDepartmentByIdAsync(DeptId, It.IsAny())) + .ReturnsAsync(new Department { DepartmentId = DeptId, ManagingUserId = ManagingUserId }); + _subscriptionsService.Setup(x => x.GetCurrentPlanForDepartmentAsync(DeptId, It.IsAny())) + .ReturnsAsync(new Plan { PlanId = 5, Cost = 500 }); + _subscriptionsService.Setup(x => x.GetCurrentPlanAddonsForDepartmentFromStripeAsync(DeptId)) + .ReturnsAsync(new List { new PlanAddon { AddonType = (int)PlanAddonTypes.ADP } }); + _featureToggleService.Setup(x => x.GetFlagByKeyAsync(FeatureFlagKeys.DepartmentProtectedDataEnrollment, true)) + .ReturnsAsync(new FeatureFlag { FlagKey = FeatureFlagKeys.DepartmentProtectedDataEnrollment, IsEnabledGlobally = true }); + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync((DepartmentDataProtectionPolicy)null); + _policyRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((p, ct, f) => Task.FromResult(p)); + + _service = new DepartmentDataProtectionService(_policyRepo.Object, _egressRepo.Object, + _departmentsService.Object, _featureToggleService.Object, _subscriptionsService.Object, + _cacheProvider.Object); + } + + #region QueueEnrollment gates + + [Test] + public async Task Enrollment_queues_for_managing_member_with_addon_and_open_gate() + { + var result = await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", "22:00", "06:00", "America/New_York"); + + result.Should().Be(DepartmentDataProtectionEnrollmentResult.Queued); + _policyRepo.Verify(x => x.InsertAsync(It.Is(p => + p.State == (int)DepartmentDataProtectionState.EnrollmentQueued && + p.ActiveMigrationKind == (int)DepartmentDataProtectionMigrationKind.Enrollment && + p.EnrollmentFlagEvaluationJson != null), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Non_managing_admin_is_denied_regardless_of_permissions() + { + var result = await _service.QueueEnrollmentAsync(DeptId, "ordinary-admin", "{}", null, null, null); + + result.Should().Be(DepartmentDataProtectionEnrollmentResult.NotManagingMember); + _policyRepo.Verify(x => x.InsertAsync(It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Free_or_missing_plan_is_denied_with_plan_required() + { + _subscriptionsService.Setup(x => x.GetCurrentPlanForDepartmentAsync(DeptId, It.IsAny())) + .ReturnsAsync((Plan)null); + + (await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", null, null, null)) + .Should().Be(DepartmentDataProtectionEnrollmentResult.PlanRequired); + + _subscriptionsService.Setup(x => x.GetCurrentPlanForDepartmentAsync(DeptId, It.IsAny())) + .ReturnsAsync(new Plan { PlanId = 1, Cost = 0 }); + + (await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", null, null, null)) + .Should().Be(DepartmentDataProtectionEnrollmentResult.PlanRequired); + } + + [Test] + public async Task Missing_or_cancelled_addon_is_denied_with_addon_required() + { + _subscriptionsService.Setup(x => x.GetCurrentPlanAddonsForDepartmentFromStripeAsync(DeptId)) + .ReturnsAsync(new List()); + + (await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", null, null, null)) + .Should().Be(DepartmentDataProtectionEnrollmentResult.AddonRequired); + + _subscriptionsService.Setup(x => x.GetCurrentPlanAddonsForDepartmentFromStripeAsync(DeptId)) + .ReturnsAsync(new List { new PlanAddon { AddonType = (int)PlanAddonTypes.ADP, IsCancelled = true } }); + + (await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", null, null, null)) + .Should().Be(DepartmentDataProtectionEnrollmentResult.AddonRequired); + } + + [Test] + public async Task Closed_missing_archived_or_erroring_gate_denies_fail_closed() + { + _featureToggleService.Setup(x => x.GetFlagByKeyAsync(FeatureFlagKeys.DepartmentProtectedDataEnrollment, true)) + .ReturnsAsync(new FeatureFlag { IsEnabledGlobally = false }); + (await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", null, null, null)) + .Should().Be(DepartmentDataProtectionEnrollmentResult.FeatureNotAvailable); + + _featureToggleService.Setup(x => x.GetFlagByKeyAsync(FeatureFlagKeys.DepartmentProtectedDataEnrollment, true)) + .ReturnsAsync((FeatureFlag)null); + (await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", null, null, null)) + .Should().Be(DepartmentDataProtectionEnrollmentResult.FeatureNotAvailable); + + _featureToggleService.Setup(x => x.GetFlagByKeyAsync(FeatureFlagKeys.DepartmentProtectedDataEnrollment, true)) + .ReturnsAsync(new FeatureFlag { IsEnabledGlobally = true, IsArchived = true }); + (await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", null, null, null)) + .Should().Be(DepartmentDataProtectionEnrollmentResult.FeatureNotAvailable); + + _featureToggleService.Setup(x => x.GetFlagByKeyAsync(FeatureFlagKeys.DepartmentProtectedDataEnrollment, true)) + .ThrowsAsync(new InvalidOperationException("flag store down")); + (await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", null, null, null)) + .Should().Be(DepartmentDataProtectionEnrollmentResult.FeatureNotAvailable); + } + + [Test] + public async Task Department_not_in_disabled_state_cannot_enroll_again() + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(new DepartmentDataProtectionPolicy + { + DepartmentDataProtectionPolicyId = 1, + DepartmentId = DeptId, + State = (int)DepartmentDataProtectionState.Enabled + }); + + (await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", null, null, null)) + .Should().Be(DepartmentDataProtectionEnrollmentResult.InvalidState); + } + + [Test] + public async Task Lost_compare_and_swap_race_reports_invalid_state() + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(new DepartmentDataProtectionPolicy + { + DepartmentDataProtectionPolicyId = 1, + DepartmentId = DeptId, + State = (int)DepartmentDataProtectionState.Disabled + }); + _policyRepo.Setup(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.Disabled, + DepartmentDataProtectionState.EnrollmentQueued, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(0); + + (await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", null, null, "UTC")) + .Should().Be(DepartmentDataProtectionEnrollmentResult.InvalidState); + } + + [Test] + public async Task Unresolvable_window_time_zone_rejects_enrollment_instead_of_queuing_a_stall() + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync((DepartmentDataProtectionPolicy)null); + + (await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", "22:00", "06:00", "Not/A_Zone")) + .Should().Be(DepartmentDataProtectionEnrollmentResult.InvalidWindow); + _policyRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Missing_window_time_zone_falls_back_to_the_department_time_zone() + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync((DepartmentDataProtectionPolicy)null); + _departmentsService.Setup(x => x.GetDepartmentByIdAsync(DeptId, It.IsAny())) + .ReturnsAsync(new Department { DepartmentId = DeptId, ManagingUserId = ManagingUserId, TimeZone = "UTC" }); + + (await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", null, null, null)) + .Should().Be(DepartmentDataProtectionEnrollmentResult.Queued); + _policyRepo.Verify(x => x.InsertAsync(It.Is(p => + p.MigrationWindowTimeZone == "UTC"), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Missing_window_time_zone_with_no_department_fallback_rejects_enrollment() + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync((DepartmentDataProtectionPolicy)null); + + // Fixture department has no TimeZone set. + (await _service.QueueEnrollmentAsync(DeptId, ManagingUserId, "{}", null, null, null)) + .Should().Be(DepartmentDataProtectionEnrollmentResult.InvalidWindow); + } + + #endregion + + #region Write-encryption and enforcement state matrix + + [TestCase(DepartmentDataProtectionState.Encrypting, null, true)] + [TestCase(DepartmentDataProtectionState.Enabled, null, true)] + [TestCase(DepartmentDataProtectionState.Rotating, null, true)] + [TestCase(DepartmentDataProtectionState.OffboardingScheduled, null, true)] + [TestCase(DepartmentDataProtectionState.Verifying, (int)DepartmentDataProtectionMigrationKind.Enrollment, true)] + [TestCase(DepartmentDataProtectionState.Verifying, (int)DepartmentDataProtectionMigrationKind.Rotation, true)] + [TestCase(DepartmentDataProtectionState.Verifying, (int)DepartmentDataProtectionMigrationKind.Offboarding, false)] + [TestCase(DepartmentDataProtectionState.Failed, (int)DepartmentDataProtectionMigrationKind.Enrollment, true)] + [TestCase(DepartmentDataProtectionState.Failed, (int)DepartmentDataProtectionMigrationKind.Offboarding, false)] + [TestCase(DepartmentDataProtectionState.Disabled, null, false)] + [TestCase(DepartmentDataProtectionState.EnrollmentQueued, null, false)] + [TestCase(DepartmentDataProtectionState.ProvisioningKey, null, false)] + [TestCase(DepartmentDataProtectionState.DisableRequested, null, false)] + [TestCase(DepartmentDataProtectionState.Decrypting, null, false)] + public async Task ShouldEncryptNewWrites_follows_the_state_machine(DepartmentDataProtectionState state, + int? migrationKind, bool expected) + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(new DepartmentDataProtectionPolicy + { + DepartmentDataProtectionPolicyId = 1, + DepartmentId = DeptId, + State = (int)state, + ActiveMigrationKind = migrationKind + }); + + (await _service.ShouldEncryptNewWritesAsync(DeptId)).Should().Be(expected); + } + + [Test] + public async Task No_policy_row_means_no_encryption_and_no_enforcement() + { + (await _service.ShouldEncryptNewWritesAsync(DeptId)).Should().BeFalse(); + (await _service.IsProtectionEnforcedAsync(DeptId)).Should().BeFalse(); + (await _service.GetStateAsync(DeptId)).Should().Be(DepartmentDataProtectionState.Disabled); + } + + #endregion + + #region Egress policy + + [Test] + public async Task Missing_egress_row_returns_generic_only_defaults() + { + _egressRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync((DepartmentProtectedDataEgressPolicy)null); + + var egress = await _service.GetEgressPolicyByDepartmentIdAsync(DeptId); + + egress.PushMode.Should().Be((int)ProtectedDataEgressMode.GenericOnly); + egress.EmailMode.Should().Be((int)ProtectedDataEgressMode.GenericOnly); + egress.SmsMode.Should().Be((int)ProtectedDataEgressMode.GenericOnly); + egress.VoiceMode.Should().Be((int)ProtectedDataEgressMode.GenericOnly); + } + + [Test] + public async Task Saving_egress_policy_bumps_the_policy_epoch() + { + var policy = new DepartmentProtectedDataEgressPolicy { DepartmentId = DeptId }; + _egressRepo.Setup(x => x.SaveOrUpdateAsync(policy, It.IsAny(), It.IsAny())) + .ReturnsAsync(policy); + _policyRepo.Setup(x => x.IncrementPolicyEpochAsync(DeptId, It.IsAny(), It.IsAny())) + .ReturnsAsync(2); + + await _service.SaveEgressPolicyAsync(policy, "admin-user"); + + _policyRepo.Verify(x => x.IncrementPolicyEpochAsync(DeptId, "admin-user", It.IsAny()), Times.Once); + } + + [Test] + public void Pin_release_is_rejected_for_push_and_email_channels() + { + var policy = new DepartmentProtectedDataEgressPolicy + { + DepartmentId = DeptId, + PushMode = (int)ProtectedDataEgressMode.ProtectedAfterPin + }; + + var act = async () => await _service.SaveEgressPolicyAsync(policy, "admin-user"); + act.Should().ThrowAsync(); + } + + [Test] + public async Task Preflight_reports_every_gate_green_on_the_happy_path() + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync((DepartmentDataProtectionPolicy)null); + + var preflight = await _service.GetEnrollmentPreflightAsync(DeptId, ManagingUserId); + + preflight.IsManagingMember.Should().BeTrue(); + preflight.HasPaidPlan.Should().BeTrue(); + preflight.HasActiveAddon.Should().BeTrue(); + preflight.GateOpen.Should().BeTrue(); + preflight.StateAllowsEnrollment.Should().BeTrue(); + preflight.Passed.Should().BeTrue(); + } + + [Test] + public async Task Preflight_is_advisory_and_value_free_on_denials() + { + // Non-managing caller + non-Disabled state: individual flags flip, nothing throws. + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(new DepartmentDataProtectionPolicy + { + DepartmentDataProtectionPolicyId = 1, + DepartmentId = DeptId, + State = (int)DepartmentDataProtectionState.Enabled + }); + + var preflight = await _service.GetEnrollmentPreflightAsync(DeptId, "someone-else"); + + preflight.IsManagingMember.Should().BeFalse(); + preflight.StateAllowsEnrollment.Should().BeFalse(); + preflight.Passed.Should().BeFalse(); + } + + [Test] + public async Task Preflight_reads_a_billing_fault_as_no_addon_and_no_plan() + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync((DepartmentDataProtectionPolicy)null); + _subscriptionsService.Setup(x => x.GetCurrentPlanForDepartmentAsync(DeptId, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("billing down")); + _subscriptionsService.Setup(x => x.GetCurrentPlanAddonsForDepartmentFromStripeAsync(DeptId)) + .ThrowsAsync(new InvalidOperationException("billing down")); + + var preflight = await _service.GetEnrollmentPreflightAsync(DeptId, ManagingUserId); + + preflight.HasPaidPlan.Should().BeFalse(); + preflight.HasActiveAddon.Should().BeFalse(); + preflight.Passed.Should().BeFalse(); + } + + [Test] + public async Task Protected_content_egress_without_a_recorded_acknowledgement_is_rejected() + { + var policy = new DepartmentProtectedDataEgressPolicy + { + DepartmentId = DeptId, + SmsMode = (int)ProtectedDataEgressMode.AllowProtectedContent + }; + + var act = async () => await _service.SaveEgressPolicyAsync(policy, "admin-user"); + await act.Should().ThrowAsync(); + _egressRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Protected_content_egress_with_a_recorded_acknowledgement_saves() + { + var policy = new DepartmentProtectedDataEgressPolicy + { + DepartmentId = DeptId, + SmsMode = (int)ProtectedDataEgressMode.AllowProtectedContent, + AcknowledgementVersion = "v1", + AcknowledgedByUserId = "admin-user" + }; + _egressRepo.Setup(x => x.SaveOrUpdateAsync(policy, It.IsAny(), It.IsAny())) + .ReturnsAsync(policy); + _policyRepo.Setup(x => x.IncrementPolicyEpochAsync(DeptId, It.IsAny(), It.IsAny())) + .ReturnsAsync(2); + + var saved = await _service.SaveEgressPolicyAsync(policy, "admin-user"); + + saved.Should().BeSameAs(policy); + } + + #endregion + + #region Offboarding + + [Test] + public async Task Cancellation_while_queued_dequeues_to_disabled() + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(new DepartmentDataProtectionPolicy + { + DepartmentDataProtectionPolicyId = 1, + DepartmentId = DeptId, + State = (int)DepartmentDataProtectionState.EnrollmentQueued + }); + _policyRepo.Setup(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.EnrollmentQueued, + DepartmentDataProtectionState.Disabled, null, It.IsAny(), It.IsAny())) + .ReturnsAsync(1); + + var result = await _service.ScheduleOffboardingAsync(DeptId, + DepartmentDataProtectionOffboardingSource.UserCancelled, DateTime.UtcNow.AddYears(1)); + + result.Should().Be(DepartmentDataProtectionEnrollmentResult.Queued); + _policyRepo.Verify(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.Enabled, + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task Revoke_offboarding_is_managing_member_only() + { + (await _service.RevokeOffboardingAsync(DeptId, "ordinary-admin")) + .Should().Be(DepartmentDataProtectionEnrollmentResult.NotManagingMember); + } + + #endregion + } +} diff --git a/Tests/Resgrid.Tests/Services/DepartmentLockServiceTests.cs b/Tests/Resgrid.Tests/Services/DepartmentLockServiceTests.cs new file mode 100644 index 000000000..d74fc7808 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DepartmentLockServiceTests.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class DepartmentLockServiceTests + { + private Mock _lockRepo; + private Mock _cacheProvider; + private DepartmentLockService _service; + + [SetUp] + public void SetUp() + { + _lockRepo = new Mock(); + _cacheProvider = new Mock(); + + // Cache pass-through: always executes the fallback so repository setups drive behavior. + _cacheProvider + .Setup(x => x.RetrieveAsync(It.IsAny(), It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((key, fallback, expiration) => fallback()); + _cacheProvider.Setup(x => x.RemoveAsync(It.IsAny())).ReturnsAsync(true); + + _service = new DepartmentLockService(_lockRepo.Object, _cacheProvider.Object); + } + + private static DepartmentOperationLock ActiveLock(int id = 5, int departmentId = 9, int expiresInMinutes = 10) => new DepartmentOperationLock + { + DepartmentOperationLockId = id, + DepartmentId = departmentId, + LockType = (int)DepartmentOperationLockType.AdpMigration, + AppliedUtc = DateTime.UtcNow.AddMinutes(-5), + HeartbeatUtc = DateTime.UtcNow, + ExpiresUtc = DateTime.UtcNow.AddMinutes(expiresInMinutes) + }; + + [Test] + public async Task Active_unexpired_lock_reports_locked() + { + _lockRepo.Setup(x => x.GetActiveByDepartmentIdAsync(9)).ReturnsAsync(ActiveLock()); + + (await _service.IsDepartmentLockedAsync(9)).Should().BeTrue(); + } + + [Test] + public async Task Lock_past_its_safety_valve_stops_enforcing_immediately() + { + _lockRepo.Setup(x => x.GetActiveByDepartmentIdAsync(9)).ReturnsAsync(ActiveLock(expiresInMinutes: -1)); + + (await _service.IsDepartmentLockedAsync(9)).Should().BeFalse(); + } + + [Test] + public async Task No_lock_reports_unlocked() + { + _lockRepo.Setup(x => x.GetActiveByDepartmentIdAsync(9)).ReturnsAsync((DepartmentOperationLock)null); + + (await _service.IsDepartmentLockedAsync(9)).Should().BeFalse(); + } + + [Test] + public async Task Blank_cache_poisoned_entity_reports_unlocked() + { + // An empty cached payload deserializes to a non-null entity with default values; it must + // never enforce a lock. + _lockRepo.Setup(x => x.GetActiveByDepartmentIdAsync(9)).ReturnsAsync(new DepartmentOperationLock()); + + (await _service.IsDepartmentLockedAsync(9)).Should().BeFalse(); + } + + [Test] + public async Task Lock_store_outage_fails_open() + { + _lockRepo.Setup(x => x.GetActiveByDepartmentIdAsync(9)).ThrowsAsync(new InvalidOperationException("db down")); + + (await _service.IsDepartmentLockedAsync(9)).Should().BeFalse( + "dispatch availability beats migration progress"); + } + + [Test] + public async Task ApplyLock_returns_lock_and_invalidates_cache_when_acquired() + { + _lockRepo.Setup(x => x.TryAcquireAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var result = await _service.ApplyLockAsync(9, DepartmentOperationLockType.AdpMigration, "ADP migration", + "corr-1", "worker:adp", DateTime.UtcNow.AddMinutes(5), DateTime.UtcNow.AddHours(8)); + + result.Should().NotBeNull(); + result.DepartmentId.Should().Be(9); + _cacheProvider.Verify(x => x.RemoveAsync(It.Is(k => k.Contains("9"))), Times.Once); + } + + [Test] + public async Task ApplyLock_returns_null_when_another_lock_holds() + { + _lockRepo.Setup(x => x.TryAcquireAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var result = await _service.ApplyLockAsync(9, DepartmentOperationLockType.AdpMigration, "ADP migration", + "corr-1", "worker:adp", DateTime.UtcNow.AddMinutes(5), null); + + result.Should().BeNull(); + } + + [Test] + public async Task Expired_sweep_releases_only_past_valve_locks() + { + var expired = ActiveLock(id: 1, departmentId: 9, expiresInMinutes: -5); + var healthy = ActiveLock(id: 2, departmentId: 10, expiresInMinutes: 30); + _lockRepo.Setup(x => x.GetAllActiveAsync()) + .ReturnsAsync(new List { expired, healthy }); + _lockRepo.Setup(x => x.ReleaseAsync(1, DepartmentOperationLockReleaseKind.Expired, It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(1); + + var released = await _service.ReleaseExpiredLocksAsync(); + + released.Should().ContainSingle(l => l.DepartmentOperationLockId == 1); + _lockRepo.Verify(x => x.ReleaseAsync(2, It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/LocalDevKeyWrappingProviderTests.cs b/Tests/Resgrid.Tests/Services/LocalDevKeyWrappingProviderTests.cs new file mode 100644 index 000000000..f36b05b9d --- /dev/null +++ b/Tests/Resgrid.Tests/Services/LocalDevKeyWrappingProviderTests.cs @@ -0,0 +1,60 @@ +using System; +using System.Security.Cryptography; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class LocalDevKeyWrappingProviderTests + { + private LocalDevKeyWrappingProvider _provider; + + [SetUp] + public void SetUp() + { + _provider = new LocalDevKeyWrappingProvider(); + } + + [Test] + public async Task Wrap_then_unwrap_round_trips_a_256_bit_key() + { + var wrapped = await _provider.GenerateWrappedDataKeyAsync(42); + + wrapped.ProviderType.Should().Be("LocalDev"); + wrapped.WrappedKeyBase64.Should().NotBeNullOrEmpty(); + + var dek = await _provider.UnwrapDataKeyAsync(42, wrapped.WrappedKeyBase64); + dek.Should().HaveCount(32); + } + + [Test] + public async Task Each_generated_key_is_distinct() + { + var first = await _provider.GenerateWrappedDataKeyAsync(42); + var second = await _provider.GenerateWrappedDataKeyAsync(42); + + first.WrappedKeyBase64.Should().NotBe(second.WrappedKeyBase64); + } + + [Test] + public async Task Unwrap_under_another_department_fails_cryptographically() + { + // Even the dev provider enforces department binding as AAD, mirroring the OpenBao + // derived-context failure mode the acceptance tests require. + var wrapped = await _provider.GenerateWrappedDataKeyAsync(42); + + var act = async () => await _provider.UnwrapDataKeyAsync(43, wrapped.WrappedKeyBase64); + await act.Should().ThrowAsync(); + } + + [Test] + public async Task Malformed_blob_is_rejected() + { + var act = async () => await _provider.UnwrapDataKeyAsync(42, Convert.ToBase64String(new byte[10])); + await act.Should().ThrowAsync(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedDataEnvelopeTests.cs b/Tests/Resgrid.Tests/Services/ProtectedDataEnvelopeTests.cs new file mode 100644 index 000000000..bb67fb55f --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedDataEnvelopeTests.cs @@ -0,0 +1,73 @@ +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ProtectedDataEnvelopeTests + { + [Test] + public void Format_then_TryParse_round_trips() + { + var envelope = ProtectedDataEnvelope.Format(3, "cGF5bG9hZA=="); + + envelope.Should().Be("rgdp:1:3:cGF5bG9hZA=="); + ProtectedDataEnvelope.TryParse(envelope, out var formatVersion, out var keyVersion, out var payload).Should().BeTrue(); + formatVersion.Should().Be(ProtectedDataEnvelope.CurrentVersion); + keyVersion.Should().Be(3); + payload.Should().Be("cGF5bG9hZA=="); + ProtectedDataEnvelope.IsEnveloped(envelope).Should().BeTrue(); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("plaintext narrative about a patient")] + [TestCase("rgdp")] + [TestCase("rgdp:")] + [TestCase("rgdp:1:3")] + [TestCase("rgdp:1:3:")] + [TestCase("rgdp:x:3:payload")] + [TestCase("rgdp:1:x:payload")] + [TestCase("rgdp:0:3:payload")] + [TestCase("rgdp:1:0:payload")] + public void TryParse_rejects_plaintext_and_malformed_values(string value) + { + ProtectedDataEnvelope.TryParse(value, out _, out _, out _).Should().BeFalse(); + ProtectedDataEnvelope.IsEnveloped(value).Should().BeFalse(); + } + + [Test] + public void Unknown_future_format_version_is_not_parseable_and_reads_as_corrupt() + { + // A future-version envelope is NOT parseable (documented TryParse contract) and must not + // read as plaintext either (HasEnvelopePrefix still true) — a prefixed value the current + // code cannot parse is corrupt, handled fail closed upstream. + var future = "rgdp:99:1:payload"; + ProtectedDataEnvelope.TryParse(future, out _, out _, out _).Should().BeFalse(); + ProtectedDataEnvelope.IsEnveloped(future).Should().BeFalse(); + ProtectedDataEnvelope.HasEnvelopePrefix(future).Should().BeTrue(); + } + + [Test] + public void Payload_containing_colons_is_preserved() + { + // base64 never contains ':' but the parser must still be robust to them (split limit 4). + ProtectedDataEnvelope.TryParse("rgdp:1:2:abc:def", out _, out _, out var payload).Should().BeTrue(); + payload.Should().Be("abc:def"); + } + + [Test] + public void Binary_prefix_is_detected_but_not_text_parseable() + { + ProtectedDataEnvelope.HasEnvelopePrefix("rgdpb:1:2:xyz").Should().BeTrue(); + ProtectedDataEnvelope.TryParse("rgdpb:1:2:xyz", out _, out _, out _).Should().BeFalse(); + } + + [Test] + public void Redaction_value_is_the_exact_contract_string() + { + ProtectedDataEnvelope.RedactionValue.Should().Be("REDACTED"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedDataGrantServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedDataGrantServiceTests.cs new file mode 100644 index 000000000..75ef31d01 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedDataGrantServiceTests.cs @@ -0,0 +1,253 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using FluentAssertions; +using Microsoft.IdentityModel.Tokens; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Protected Data Grant issue/validate (ADP plan section 3): claims binding, pinned ES256, + /// tamper/tenant-swap/epoch/scope/lifetime rejection, and fail-closed behavior when key + /// material is absent. Certificates are ephemeral in-memory ECDSA — no files, no config paths. + /// + [TestFixture] + public class ProtectedDataGrantServiceTests + { + private X509Certificate2 _signingCertificate; + private X509Certificate2 _publicOnlyCertificate; + private ProtectedDataGrantService _service; + + [OneTimeSetUp] + public void OneTimeSetUp() + { + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var request = new CertificateRequest("CN=adp-grant-tests", ecdsa, HashAlgorithmName.SHA256); + _signingCertificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(2)); + _publicOnlyCertificate = X509CertificateLoader.LoadCertificate(_signingCertificate.Export(X509ContentType.Cert)); + } + + [OneTimeTearDown] + public void OneTimeTearDown() + { + _signingCertificate?.Dispose(); + _publicOnlyCertificate?.Dispose(); + } + + [SetUp] + public void SetUp() + { + // Identity-tier shape: private key signs; validation happens against the public part. + _service = new ProtectedDataGrantService(() => _signingCertificate, () => _publicOnlyCertificate); + } + + private static ProtectedDataGrantIssueRequest Request(int departmentId = 42, long policyEpoch = 7, + int windowMinutes = 15) => new ProtectedDataGrantIssueRequest + { + UserId = "user-1", + DepartmentId = departmentId, + SessionId = "session-9", + ClientApp = (int)UserSessionClientApplication.Responder, + PolicyEpoch = policyEpoch, + WindowMinutes = windowMinutes, + Scopes = new[] { ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write }, + MfaAtUtc = DateTime.UtcNow + }; + + [Test] + public void Issue_and_validate_roundtrip_binds_every_claim() + { + var issued = _service.IssueGrant(Request()); + + issued.GrantId.Should().NotBeNullOrWhiteSpace(); + issued.Token.Should().NotBeNullOrWhiteSpace(); + + var outcome = _service.ValidateGrant(issued.Token, 42, 7, ProtectedDataGrantScopes.Read, out var grant); + + outcome.Should().Be(ProtectedDataGrantValidationOutcome.Valid); + grant.GrantId.Should().Be(issued.GrantId); + grant.UserId.Should().Be("user-1"); + grant.DepartmentId.Should().Be(42); + grant.SessionId.Should().Be("session-9"); + grant.ClientApp.Should().Be((int)UserSessionClientApplication.Responder); + grant.PolicyEpoch.Should().Be(7); + grant.Scopes.Should().BeEquivalentTo(ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write); + grant.ExpiresOnUtc.Should().BeCloseTo(issued.ExpiresOnUtc, TimeSpan.FromSeconds(2)); + grant.MfaAtUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); + } + + [Test] + public void Tampered_token_is_invalid() + { + var issued = _service.IssueGrant(Request()); + var parts = issued.Token.Split('.'); + // Flip a character inside the signed payload. + var payload = parts[1]; + var flipped = (payload[3] == 'A' ? 'B' : 'A'); + parts[1] = payload.Substring(0, 3) + flipped + payload.Substring(4); + var tampered = string.Join(".", parts); + + _service.ValidateGrant(tampered, 42, 7, ProtectedDataGrantScopes.Read, out var grant) + .Should().Be(ProtectedDataGrantValidationOutcome.Invalid); + grant.Should().BeNull(); + } + + [Test] + public void Department_swap_is_rejected() + { + var issued = _service.IssueGrant(Request(departmentId: 42)); + + _service.ValidateGrant(issued.Token, 43, 7, ProtectedDataGrantScopes.Read, out var grant) + .Should().Be(ProtectedDataGrantValidationOutcome.WrongDepartment); + grant.Should().BeNull(); + } + + [Test] + public void Policy_epoch_bump_revokes_earlier_grants() + { + var issued = _service.IssueGrant(Request(policyEpoch: 7)); + + _service.ValidateGrant(issued.Token, 42, 8, ProtectedDataGrantScopes.Read, out _) + .Should().Be(ProtectedDataGrantValidationOutcome.EpochRevoked); + } + + [Test] + public void Grant_claiming_a_future_epoch_is_equally_revoked() + { + var issued = _service.IssueGrant(Request(policyEpoch: 9)); + + _service.ValidateGrant(issued.Token, 42, 7, ProtectedDataGrantScopes.Read, out _) + .Should().Be(ProtectedDataGrantValidationOutcome.EpochRevoked); + } + + [Test] + public void Missing_scope_is_rejected() + { + var request = Request(); + request.Scopes = new[] { ProtectedDataGrantScopes.Read }; + var issued = _service.IssueGrant(request); + + _service.ValidateGrant(issued.Token, 42, 7, ProtectedDataGrantScopes.Write, out _) + .Should().Be(ProtectedDataGrantValidationOutcome.MissingScope); + } + + [Test] + public void Expired_grant_is_rejected_beyond_the_bounded_skew() + { + var issued = _service.IssueGrant(Request(windowMinutes: 15)); + + // Inside skew: still valid; past skew: expired. Lifetime is absolute. + _service.ValidateGrant(issued.Token, 42, 7, ProtectedDataGrantScopes.Read, out _, + utcNow: DateTime.UtcNow.AddMinutes(15).AddSeconds(10)) + .Should().Be(ProtectedDataGrantValidationOutcome.Valid); + _service.ValidateGrant(issued.Token, 42, 7, ProtectedDataGrantScopes.Read, out _, + utcNow: DateTime.UtcNow.AddMinutes(16)) + .Should().Be(ProtectedDataGrantValidationOutcome.Expired); + } + + [Test] + public void Window_is_clamped_to_the_operator_ceiling() + { + var issued = _service.IssueGrant(Request(windowMinutes: 100000)); + + issued.ExpiresOnUtc.Should().BeOnOrBefore( + DateTime.UtcNow.AddMinutes(Resgrid.Config.DataProtectionConfig.StepUpMaximumMinutes).AddSeconds(5)); + } + + [Test] + public void Algorithm_confusion_with_a_symmetric_key_is_invalid() + { + // A forged HS256 token keyed on public material must never validate against the pinned + // ES256 check. + var handler = new JwtSecurityTokenHandler(); + var forged = handler.WriteToken(new JwtSecurityToken( + issuer: Resgrid.Config.DataProtectionConfig.GrantIssuer, + audience: Resgrid.Config.DataProtectionConfig.GrantAudience, + claims: new[] + { + new System.Security.Claims.Claim("sub", "user-1"), + new System.Security.Claims.Claim("dept", "42"), + new System.Security.Claims.Claim("policy_epoch", "7"), + new System.Security.Claims.Claim("scope", ProtectedDataGrantScopes.Read) + }, + notBefore: DateTime.UtcNow, + expires: DateTime.UtcNow.AddMinutes(15), + signingCredentials: new SigningCredentials( + new SymmetricSecurityKey(SHA256.HashData(_publicOnlyCertificate.RawData)), + SecurityAlgorithms.HmacSha256))); + + _service.ValidateGrant(forged, 42, 7, ProtectedDataGrantScopes.Read, out _) + .Should().Be(ProtectedDataGrantValidationOutcome.Invalid); + } + + [Test] + public void Broker_shape_validates_with_public_certificate_only_and_cannot_issue() + { + var issued = _service.IssueGrant(Request()); + var brokerShape = new ProtectedDataGrantService(() => null, () => _publicOnlyCertificate); + + brokerShape.CanIssueGrants.Should().BeFalse(); + brokerShape.CanValidateGrants.Should().BeTrue(); + brokerShape.ValidateGrant(issued.Token, 42, 7, ProtectedDataGrantScopes.Read, out _) + .Should().Be(ProtectedDataGrantValidationOutcome.Valid); + + Action issueOnBroker = () => brokerShape.IssueGrant(Request()); + issueOnBroker.Should().Throw(); + } + + [Test] + public void Missing_validation_material_fails_closed_as_not_configured() + { + var unconfigured = new ProtectedDataGrantService(() => null, () => null); + var issued = _service.IssueGrant(Request()); + + unconfigured.CanValidateGrants.Should().BeFalse(); + unconfigured.ValidateGrant(issued.Token, 42, 7, ProtectedDataGrantScopes.Read, out var grant) + .Should().Be(ProtectedDataGrantValidationOutcome.NotConfigured); + grant.Should().BeNull(); + } + + [Test] + public void Garbage_and_empty_tokens_are_invalid_never_throwing() + { + _service.ValidateGrant(null, 42, 7, ProtectedDataGrantScopes.Read, out _) + .Should().Be(ProtectedDataGrantValidationOutcome.Invalid); + _service.ValidateGrant("", 42, 7, ProtectedDataGrantScopes.Read, out _) + .Should().Be(ProtectedDataGrantValidationOutcome.Invalid); + _service.ValidateGrant("not.a.token", 42, 7, ProtectedDataGrantScopes.Read, out _) + .Should().Be(ProtectedDataGrantValidationOutcome.Invalid); + } + + [Test] + public void Issue_refuses_unusable_requests() + { + Action noUser = () => _service.IssueGrant(new ProtectedDataGrantIssueRequest + { + DepartmentId = 42, + Scopes = new[] { ProtectedDataGrantScopes.Read }, + WindowMinutes = 15 + }); + noUser.Should().Throw(); + + Action noDepartment = () => _service.IssueGrant(new ProtectedDataGrantIssueRequest + { + UserId = "user-1", + Scopes = new[] { ProtectedDataGrantScopes.Read }, + WindowMinutes = 15 + }); + noDepartment.Should().Throw(); + + Action noScopes = () => _service.IssueGrant(new ProtectedDataGrantIssueRequest + { + UserId = "user-1", + DepartmentId = 42, + WindowMinutes = 15 + }); + noScopes.Should().Throw(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedFieldCatalogTests.cs b/Tests/Resgrid.Tests/Services/ProtectedFieldCatalogTests.cs new file mode 100644 index 000000000..9110e0395 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedFieldCatalogTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ProtectedFieldCatalogTests + { + private ProtectedFieldCatalog _catalog; + + [SetUp] + public void SetUp() + { + _catalog = new ProtectedFieldCatalog(); + } + + [Test] + public void Field_ids_are_unique_and_lowercase() + { + var all = _catalog.GetAll(); + + all.Should().NotBeEmpty(); + all.Select(e => e.FieldId).Should().OnlyHaveUniqueItems( + "FieldIds are AAD components and must never collide"); + all.Should().OnlyContain(e => e.FieldId == e.FieldId.ToLowerInvariant(), + "FieldIds are stable lowercase identifiers"); + all.Should().OnlyContain(e => e.FieldId == $"{e.TableName.ToLowerInvariant()}.{e.ColumnName.ToLowerInvariant()}", + "FieldId convention is table.column so ids stay derivable and collision-free"); + } + + [Test] + public void P0_families_are_present() + { + _catalog.GetById("calls.name").Should().NotBeNull(); + _catalog.GetById("calls.natureofcall").Classification.Should().Be(ProtectedFieldClassification.Phi); + _catalog.GetById("calllogs.narrative").Should().NotBeNull(); + _catalog.GetById("contacts.email").Should().NotBeNull(); + _catalog.GetById("contactnotes.note").Should().NotBeNull(); + _catalog.GetById("departmentmembersensitivedata.identificationnumber").Should().NotBeNull(); + } + + [Test] + public void Storage_kinds_match_column_types() + { + _catalog.GetById("callattachments.data").StorageKind.Should().Be(ProtectedFieldStorageKind.Binary); + _catalog.GetById("contacts.image").StorageKind.Should().Be(ProtectedFieldStorageKind.Binary); + _catalog.GetById("callnotes.latitude").StorageKind.Should().Be(ProtectedFieldStorageKind.CompanionColumn); + _catalog.GetById("callnotes.longitude").StorageKind.Should().Be(ProtectedFieldStorageKind.CompanionColumn); + _catalog.GetById("calls.notes").StorageKind.Should().Be(ProtectedFieldStorageKind.Text); + } + + [Test] + public void Table_lookup_is_case_insensitive() + { + _catalog.GetForTable("CALLS").Should().NotBeEmpty(); + _catalog.GetForTable("calls").Select(e => e.FieldId) + .Should().BeEquivalentTo(_catalog.GetForTable("Calls").Select(e => e.FieldId)); + _catalog.IsProtectedField("calls", "NAME").Should().BeTrue(); + _catalog.IsProtectedField("Calls", "Number").Should().BeFalse( + "the system-generated call number stays plaintext"); + _catalog.GetForTable("NoSuchTable").Should().BeEmpty(); + } + + [Test] + public void Permissions_follow_family_boundaries() + { + _catalog.GetForTable("Calls").Should().OnlyContain(e => e.ViewPermission == PermissionTypes.ViewProtectedCallData); + _catalog.GetForTable("Calls").Should().OnlyContain(e => e.EditPermission == PermissionTypes.EditProtectedCallData); + _catalog.GetForTable("Contacts").Should().OnlyContain(e => e.ViewPermission == PermissionTypes.ViewProtectedContactData); + _catalog.GetForTable("DepartmentMemberSensitiveData") + .Should().OnlyContain(e => e.ViewPermission == PermissionTypes.ViewProtectedPersonnelData); + } + + [Test] + public void Version_is_one_and_all_entries_belong_to_it() + { + _catalog.Version.Should().Be(1); + _catalog.GetAll().Should().OnlyContain(e => e.AddedInCatalogVersion == 1); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedFieldCryptoServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedFieldCryptoServiceTests.cs new file mode 100644 index 000000000..3d1762093 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedFieldCryptoServiceTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ProtectedFieldCryptoServiceTests + { + private ProtectedFieldCryptoService _crypto; + private byte[] _dek; + + [SetUp] + public void SetUp() + { + _crypto = new ProtectedFieldCryptoService(); + _dek = new byte[32]; + RandomNumberGenerator.Fill(_dek); + } + + [Test] + public void Text_round_trip_preserves_value_and_envelope_shape() + { + var envelope = _crypto.EncryptText(_dek, 3, "Chest pain, 62yo male", 42, "calls.natureofcall", "1001", 1); + + envelope.Should().StartWith("rgdp:1:3:"); + ProtectedDataEnvelope.IsEnveloped(envelope).Should().BeTrue(); + + _crypto.DecryptText(_dek, envelope, 42, "calls.natureofcall", "1001", 1) + .Should().Be("Chest pain, 62yo male"); + } + + [TestCase(43, "calls.natureofcall", "1001", 1, Description = "different department")] + [TestCase(42, "calls.notes", "1001", 1, Description = "different field")] + [TestCase(42, "calls.natureofcall", "1002", 1, Description = "different row")] + [TestCase(42, "calls.natureofcall", "1001", 2, Description = "different catalog version")] + public void Any_aad_component_mismatch_fails_authentication(int departmentId, string fieldId, string rowKey, int catalogVersion) + { + var envelope = _crypto.EncryptText(_dek, 1, "secret", 42, "calls.natureofcall", "1001", 1); + + var act = () => _crypto.DecryptText(_dek, envelope, departmentId, fieldId, rowKey, catalogVersion); + act.Should().Throw( + "moving ciphertext between tenants, rows, fields, or catalog versions must fail AEAD authentication"); + } + + [Test] + public void Tampered_ciphertext_fails_authentication() + { + var envelope = _crypto.EncryptText(_dek, 1, "secret", 42, "calls.notes", "7", 1); + var payload = Convert.FromBase64String(envelope.Split(':', 4)[3]); + payload[payload.Length - 1] ^= 0x01; + var tampered = "rgdp:1:1:" + Convert.ToBase64String(payload); + + var act = () => _crypto.DecryptText(_dek, tampered, 42, "calls.notes", "7", 1); + act.Should().Throw(); + } + + [Test] + public void Encrypting_an_enveloped_value_is_refused() + { + var envelope = _crypto.EncryptText(_dek, 1, "secret", 42, "calls.notes", "7", 1); + + var act = () => _crypto.EncryptText(_dek, 1, envelope, 42, "calls.notes", "7", 1); + act.Should().Throw( + "the double-encryption guard must make re-encrypting an envelope impossible"); + } + + [Test] + public void Binary_round_trip_with_header_and_key_version() + { + var blob = new byte[2048]; + RandomNumberGenerator.Fill(blob); + + var envelope = _crypto.EncryptBinary(_dek, 5, blob, 42, "callattachments.data", "88", 1); + + _crypto.IsBinaryEnveloped(envelope).Should().BeTrue(); + Encoding.ASCII.GetString(envelope, 0, 10).Should().Be("rgdpb:1:5:"); + _crypto.TryGetBinaryEnvelopeKeyVersion(envelope, out var keyVersion).Should().BeTrue(); + keyVersion.Should().Be(5); + + _crypto.DecryptBinary(_dek, envelope, 42, "callattachments.data", "88", 1).Should().Equal(blob); + } + + [Test] + public void Binary_double_encrypt_is_refused_and_plain_blobs_are_not_enveloped() + { + var blob = new byte[64]; + RandomNumberGenerator.Fill(blob); + // Guarantee the random blob cannot accidentally start with the header. + blob[0] = 0x00; + + _crypto.IsBinaryEnveloped(blob).Should().BeFalse(); + _crypto.TryGetBinaryEnvelopeKeyVersion(blob, out _).Should().BeFalse(); + + var envelope = _crypto.EncryptBinary(_dek, 1, blob, 42, "contacts.image", "c-1", 1); + var act = () => _crypto.EncryptBinary(_dek, 1, envelope, 42, "contacts.image", "c-1", 1); + act.Should().Throw(); + } + + [Test] + public void Binary_encrypt_refuses_a_non_positive_key_version() + { + // A zero/negative version would produce a blob TryParseBinaryHeader always rejects — + // an undecryptable write must fail at write time (same invariant as the text path). + var blob = new byte[16]; + RandomNumberGenerator.Fill(blob); + blob[0] = 0x00; + + var zero = () => _crypto.EncryptBinary(_dek, 0, blob, 42, "contacts.image", "c-1", 1); + zero.Should().Throw(); + + var negative = () => _crypto.EncryptBinary(_dek, -3, blob, 42, "contacts.image", "c-1", 1); + negative.Should().Throw(); + } + + [Test] + public void Binary_aad_mismatch_fails_authentication() + { + var blob = new byte[16]; + RandomNumberGenerator.Fill(blob); + blob[0] = 0x00; + + var envelope = _crypto.EncryptBinary(_dek, 1, blob, 42, "contacts.image", "c-1", 1); + + var act = () => _crypto.DecryptBinary(_dek, envelope, 43, "contacts.image", "c-1", 1); + act.Should().Throw(); + } + + [Test] + public void Wrong_dek_size_is_rejected() + { + var shortKey = new byte[16]; + var act = () => _crypto.EncryptText(shortKey, 1, "x", 42, "calls.notes", "7", 1); + act.Should().Throw(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedProjectionServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedProjectionServiceTests.cs new file mode 100644 index 000000000..90ab5589e --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedProjectionServiceTests.cs @@ -0,0 +1,210 @@ +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class ProtectedProjectionServiceTests + { + private const int DeptId = 42; + + private Mock _protection; + private ProtectedProjectionService _service; + + [SetUp] + public void SetUp() + { + _protection = new Mock(); + _service = new ProtectedProjectionService(_protection.Object, new ProtectedFieldCatalog()); + } + + private sealed class FakeCallAddedEvent + { + public int DepartmentId { get; set; } = DeptId; + public int CallId { get; set; } = 1001; + public string Name { get; set; } = "Structure Fire"; + public string NatureOfCall { get; set; } = "Smoke showing, occupant trapped"; + public string Priority { get; set; } = "High"; + public FakeNote[] Notes2 { get; set; } = { new FakeNote() }; + public byte[] Data { get; set; } = { 1, 2, 3 }; + } + + private sealed class FakeNote + { + public string Note { get; set; } = "Patient is diabetic"; + public string AddedByUserId { get; set; } = "user-1"; + } + + [Test] + public async Task Unprotected_department_serializes_plainly() + { + _protection.Setup(x => x.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(false); + + var json = await _service.BuildSafeWorkflowPayloadAsync(DeptId, new FakeCallAddedEvent()); + var parsed = JObject.Parse(json); + + parsed["Name"].Value().Should().Be("Structure Fire"); + parsed["is_redacted"].Should().BeNull(); + } + + [Test] + public async Task Enforced_department_gets_redacted_scalars_omitted_binaries_and_metadata() + { + _protection.Setup(x => x.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(true); + + var json = await _service.BuildSafeWorkflowPayloadAsync(DeptId, new FakeCallAddedEvent()); + var parsed = JObject.Parse(json); + + parsed["Name"].Value().Should().Be("REDACTED", "cataloged scalars become the exact placeholder"); + parsed["NatureOfCall"].Value().Should().Be("REDACTED"); + parsed["Data"].Should().BeNull("cataloged binaries are omitted, never inlined"); + + // Structural and non-cataloged values survive so routing still works. + parsed["DepartmentId"].Value().Should().Be(DeptId); + parsed["CallId"].Value().Should().Be(1001); + parsed["Priority"].Value().Should().Be("High"); + + // Nested user-authored content is redacted wherever it appears in the graph. + parsed["Notes2"][0]["Note"].Value().Should().Be("REDACTED"); + parsed["Notes2"][0]["AddedByUserId"].Value().Should().Be("user-1"); + + parsed["is_redacted"].Value().Should().BeTrue(); + parsed["catalog_version"].Value().Should().Be(new ProtectedFieldCatalog().Version); + parsed["redacted_fields"].Values().Should().Contain(new[] { "Name", "NatureOfCall", "Note", "Data" }); + } + + [Test] + public async Task Unknown_protection_state_redacts_defensively() + { + _protection.Setup(x => x.IsProtectionEnforcedAsync(DeptId)) + .ThrowsAsync(new System.InvalidOperationException("state store down")); + + var json = await _service.BuildSafeWorkflowPayloadAsync(DeptId, new FakeCallAddedEvent()); + var parsed = JObject.Parse(json); + + parsed["Name"].Value().Should().Be("REDACTED", + "an unknown protection state must never leak plaintext"); + parsed["is_redacted"].Value().Should().BeTrue(); + } + + [Test] + public async Task Null_payload_returns_null() + { + (await _service.BuildSafeWorkflowPayloadAsync(DeptId, null)).Should().BeNull(); + } + + #region Notification-safe call + + private static Resgrid.Model.Call ProtectedCall() => new Resgrid.Model.Call + { + CallId = 1001, + DepartmentId = DeptId, + Number = "2026-134", + Priority = 3, + Name = "Cardiac Arrest - Smith Residence", + NatureOfCall = "62yo male, CPR in progress", + Address = "123 Main St", + GeoLocationData = "39.1,-84.5", + ContactName = "Jane Smith", + ContactNumber = "555-0100", + Notes = "History of heart disease" + }; + + private void SetupEgress(Resgrid.Model.ProtectedDataEgressMode push = Resgrid.Model.ProtectedDataEgressMode.GenericOnly, + Resgrid.Model.ProtectedDataEgressMode sms = Resgrid.Model.ProtectedDataEgressMode.GenericOnly) + { + _protection.Setup(x => x.GetEgressPolicyByDepartmentIdAsync(DeptId, It.IsAny())) + .ReturnsAsync(new Resgrid.Model.DepartmentProtectedDataEgressPolicy + { + DepartmentId = DeptId, + PushMode = (int)push, + SmsMode = (int)sms, + EmailMode = (int)Resgrid.Model.ProtectedDataEgressMode.GenericOnly, + VoiceMode = (int)Resgrid.Model.ProtectedDataEgressMode.GenericOnly + }); + } + + [Test] + public async Task Unprotected_department_gets_the_original_call_by_reference() + { + _protection.Setup(x => x.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(false); + var call = ProtectedCall(); + + var safe = await _service.BuildNotificationSafeCallAsync(DeptId, call, Resgrid.Model.ProtectedDataEgressChannel.Sms); + + safe.Should().BeSameAs(call); + } + + [Test] + public async Task Generic_only_channel_gets_the_sanitized_clone() + { + _protection.Setup(x => x.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(true); + SetupEgress(); + var call = ProtectedCall(); + + var safe = await _service.BuildNotificationSafeCallAsync(DeptId, call, Resgrid.Model.ProtectedDataEgressChannel.Sms); + + safe.Should().NotBeSameAs(call); + safe.CallId.Should().Be(1001); + safe.Number.Should().Be("2026-134", "the system-generated call number is allowlisted"); + safe.Name.Should().Be("2026-134"); + safe.NatureOfCall.Should().Be(ProtectedProjectionService.GenericDispatchText); + safe.Address.Should().BeNull(); + safe.GeoLocationData.Should().BeNull(); + safe.ContactName.Should().BeNull(); + safe.ContactNumber.Should().BeNull(); + safe.Notes.Should().BeNull(); + safe.Priority.Should().Be(3, "priority/color routing survives"); + } + + [Test] + public async Task Allow_protected_content_mode_passes_the_original_for_that_channel_only() + { + _protection.Setup(x => x.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(true); + SetupEgress(push: Resgrid.Model.ProtectedDataEgressMode.AllowProtectedContent); + var call = ProtectedCall(); + + (await _service.BuildNotificationSafeCallAsync(DeptId, call, Resgrid.Model.ProtectedDataEgressChannel.Push)) + .Should().BeSameAs(call, "the department explicitly acknowledged protected push content"); + (await _service.BuildNotificationSafeCallAsync(DeptId, call, Resgrid.Model.ProtectedDataEgressChannel.Sms)) + .Should().NotBeSameAs(call, "each channel is an independent choice"); + } + + [Test] + public async Task Chat_platforms_are_always_generic_for_protected_departments() + { + _protection.Setup(x => x.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(true); + SetupEgress(push: Resgrid.Model.ProtectedDataEgressMode.AllowProtectedContent, + sms: Resgrid.Model.ProtectedDataEgressMode.AllowProtectedContent); + + (await _service.BuildNotificationSafeCallAsync(DeptId, ProtectedCall(), Resgrid.Model.ProtectedDataEgressChannel.ChatPlatform)) + .NatureOfCall.Should().Be(ProtectedProjectionService.GenericDispatchText, + "third-party chat egress has no allow mode"); + } + + [Test] + public async Task Unknown_protection_or_egress_state_sanitizes_defensively() + { + _protection.Setup(x => x.IsProtectionEnforcedAsync(DeptId)) + .ThrowsAsync(new System.InvalidOperationException("state store down")); + + (await _service.BuildNotificationSafeCallAsync(DeptId, ProtectedCall(), Resgrid.Model.ProtectedDataEgressChannel.Sms)) + .NatureOfCall.Should().Be(ProtectedProjectionService.GenericDispatchText); + + _protection.Reset(); + _protection.Setup(x => x.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(true); + _protection.Setup(x => x.GetEgressPolicyByDepartmentIdAsync(DeptId, It.IsAny())) + .ThrowsAsync(new System.InvalidOperationException("egress store down")); + + (await _service.BuildNotificationSafeCallAsync(DeptId, ProtectedCall(), Resgrid.Model.ProtectedDataEgressChannel.Push)) + .NatureOfCall.Should().Be(ProtectedProjectionService.GenericDispatchText); + } + + #endregion + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs index 563c10b76..81941567c 100644 --- a/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/CallsControllerTests.cs @@ -31,6 +31,7 @@ public class CallsControllerTests private Mock _callsService; private Mock _authorizationService; private Mock _protocolsService; + private Mock _dataProtectionService; private CallsController _controller; private Activity _activity; @@ -40,6 +41,7 @@ public void SetUp() _callsService = new Mock(); _authorizationService = new Mock(); _protocolsService = new Mock(); + _dataProtectionService = new Mock(); var httpContext = new DefaultHttpContext { @@ -75,7 +77,8 @@ public void SetUp() Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of()) + Mock.Of(), + _dataProtectionService.Object) { ControllerContext = new ControllerContext { HttpContext = httpContext } }; @@ -152,6 +155,94 @@ public async Task GetCall_HydratesProtocols_UsingDispatchProtocolId() _protocolsService.Verify(service => service.GetProtocolByIdAsync(999), Times.Never); } + private void UseBigBoardSession() + { + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.PrimarySid, UserId), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()), + new Claim(Resgrid.Model.Security.SessionClaimTypes.ClientApp, + ((int)UserSessionClientApplication.BigBoard).ToString()) + }, "test")) + }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + } + + private Call SetupProtectedCall() + { + var call = new Call + { + CallId = 42, + DepartmentId = DepartmentId, + Number = "2026-134", + Name = "Cardiac Arrest - Smith Residence", + NatureOfCall = "62yo male, CPR in progress", + Address = "123 Main St", + LoggedOn = DateTime.UtcNow + }; + + _callsService.Setup(service => service.GetCallByIdAsync(42, It.IsAny())).ReturnsAsync(call); + _callsService + .Setup(service => service.PopulateCallData(call, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(call); + _authorizationService.Setup(service => service.CanUserViewCallAsync(UserId, 42)).ReturnsAsync(true); + + return call; + } + + [Test] + public async Task GetCall_ReturnsSafeShell_ForBigBoardSessionOfProtectedDepartment() + { + SetupProtectedCall(); + _dataProtectionService.Setup(x => x.IsProtectionEnforcedAsync(DepartmentId)).ReturnsAsync(true); + UseBigBoardSession(); + + var response = await _controller.GetCall("42"); + + var result = response.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + result.Data.Name.Should().Be("Protected incident — open Resgrid to view details."); + result.Data.Nature.Should().BeNull(); + result.Data.Address.Should().BeNull(); + result.Data.ContactName.Should().BeNull(); + result.Data.Number.Should().Be("2026-134", "the system-generated call number is allowlisted"); + result.Data.UdfValues.Should().BeNullOrEmpty("submitted UDF free text is suppressed for the shell"); + } + + [Test] + public async Task GetCall_IsUnchangedForBigBoard_WhenDepartmentIsNotProtected() + { + SetupProtectedCall(); + _dataProtectionService.Setup(x => x.IsProtectionEnforcedAsync(DepartmentId)).ReturnsAsync(false); + UseBigBoardSession(); + + var response = await _controller.GetCall("42"); + + var result = response.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + result.Data.Name.Should().Be("Cardiac Arrest - Smith Residence"); + result.Data.Address.Should().Be("123 Main St"); + } + + [Test] + public async Task GetCall_IsUnchangedForAttendedClients_EvenWhenProtected() + { + SetupProtectedCall(); + _dataProtectionService.Setup(x => x.IsProtectionEnforcedAsync(DepartmentId)).ReturnsAsync(true); + + var response = await _controller.GetCall("42"); + + var result = response.Result.Should().BeOfType().Subject.Value + .Should().BeOfType().Subject; + result.Data.Name.Should().Be("Cardiac Arrest - Smith Residence", + "attended clients are gated by grants in a later phase, never by the BigBoard shell"); + } + [TestCase(null)] [TestCase("")] [TestCase("not-a-number")] diff --git a/Tests/Resgrid.Tests/Workers/AdpMigrationLogicTests.cs b/Tests/Resgrid.Tests/Workers/AdpMigrationLogicTests.cs new file mode 100644 index 000000000..8415b0577 --- /dev/null +++ b/Tests/Resgrid.Tests/Workers/AdpMigrationLogicTests.cs @@ -0,0 +1,436 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Config; +using Resgrid.Model; +using Resgrid.Model.Identity; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Tests.Workers +{ + [TestFixture] + public class AdpMigrationLogicTests + { + private const int DeptId = 42; + + private Mock _lockService; + private Mock _policyRepo; + private Mock _protectionService; + private Mock _keyService; + private Mock _engine; + private Mock _departmentsService; + private Mock _emailService; + private AdpMigrationLogic _logic; + + private bool _originalPaused; + private int _originalConcurrency; + + [SetUp] + public void SetUp() + { + _originalPaused = DataProtectionConfig.MigrationQueuePaused; + _originalConcurrency = DataProtectionConfig.MigrationNightlyConcurrency; + + _lockService = new Mock(); + _policyRepo = new Mock(); + _protectionService = new Mock(); + _keyService = new Mock(); + _engine = new Mock(); + _departmentsService = new Mock(); + _emailService = new Mock(); + + _lockService.Setup(x => x.ReleaseExpiredLocksAsync(It.IsAny())) + .ReturnsAsync(new List()); + + // The mocked engine is a "real" one; availability gating has its own tests below. + _engine.SetupGet(x => x.IsAvailable).Returns(true); + _lockService.Setup(x => x.ApplyLockAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new DepartmentOperationLock { DepartmentOperationLockId = 11, DepartmentId = DeptId }); + _lockService.Setup(x => x.ReleaseLockAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _lockService.Setup(x => x.HeartbeatAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Every CAS transition succeeds unless a test narrows it. + _policyRepo.Setup(x => x.TryTransitionStateAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(1); + _policyRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns((p, ct, f) => Task.FromResult(p)); + + _keyService.Setup(x => x.ProvisionNextKeyVersionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new DepartmentDataProtectionKey { DepartmentId = DeptId, Version = 1, Status = (int)DepartmentDataProtectionKeyStatus.Active }); + + // No admins so notification fan-out is a no-op in most tests. + _departmentsService.Setup(x => x.GetAllAdminsForDepartmentAsync(It.IsAny())) + .ReturnsAsync(new List()); + + _logic = new AdpMigrationLogic(_lockService.Object, _policyRepo.Object, _protectionService.Object, + _keyService.Object, _engine.Object, new ProtectedFieldCatalog(), _departmentsService.Object, + _emailService.Object); + } + + [TearDown] + public void TearDown() + { + DataProtectionConfig.MigrationQueuePaused = _originalPaused; + DataProtectionConfig.MigrationNightlyConcurrency = _originalConcurrency; + } + + private static DepartmentDataProtectionPolicy Policy(DepartmentDataProtectionState state, + DepartmentDataProtectionMigrationKind? kind = null, bool windowAlwaysOpen = true) => new DepartmentDataProtectionPolicy + { + DepartmentDataProtectionPolicyId = 1, + DepartmentId = DeptId, + State = (int)state, + ActiveMigrationKind = kind.HasValue ? (int?)kind.Value : null, + // Window ends are exclusive (time < end), so a "23:59" end leaves a one-minute daily + // hole that fails any CI run landing in it. "1.00:00" (24h) is open at every instant; + // equal start/end is closed at every instant. + MigrationWindowStartLocal = windowAlwaysOpen ? "00:00" : "03:00", + MigrationWindowEndLocal = windowAlwaysOpen ? "1.00:00" : "03:00", + MigrationWindowTimeZone = "UTC", + CreatedOn = DateTime.UtcNow.AddDays(-1) + }; + + private void SetupPolicies(params DepartmentDataProtectionPolicy[] policies) + { + _policyRepo.Setup(x => x.GetAllAsync()).ReturnsAsync(policies); + foreach (var policy in policies) + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(policy.DepartmentId)).ReturnsAsync(policy); + } + + #region Sweep behaviors + + [Test] + public async Task Expired_lock_fails_the_in_flight_migration_at_its_cursor() + { + SetupPolicies(Policy(DepartmentDataProtectionState.Encrypting, DepartmentDataProtectionMigrationKind.Enrollment, windowAlwaysOpen: false)); + _lockService.Setup(x => x.ReleaseExpiredLocksAsync(It.IsAny())) + .ReturnsAsync(new List + { + new DepartmentOperationLock + { + DepartmentOperationLockId = 5, + DepartmentId = DeptId, + LockType = (int)DepartmentOperationLockType.AdpMigration + } + }); + + var result = await _logic.Process(CancellationToken.None); + + result.Item1.Should().BeTrue(); + _policyRepo.Verify(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.Encrypting, + DepartmentDataProtectionState.Failed, (int)DepartmentDataProtectionMigrationKind.Enrollment, + It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Due_offboarding_flips_to_disable_requested() + { + var policy = Policy(DepartmentDataProtectionState.OffboardingScheduled, windowAlwaysOpen: false); + policy.OffboardingEffectiveOn = DateTime.UtcNow.AddMinutes(-5); + SetupPolicies(policy); + + await _logic.Process(CancellationToken.None); + + _policyRepo.Verify(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.OffboardingScheduled, + DepartmentDataProtectionState.DisableRequested, (int)DepartmentDataProtectionMigrationKind.Offboarding, + It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Future_offboarding_is_left_alone() + { + var policy = Policy(DepartmentDataProtectionState.OffboardingScheduled, windowAlwaysOpen: false); + policy.OffboardingEffectiveOn = DateTime.UtcNow.AddDays(30); + SetupPolicies(policy); + + await _logic.Process(CancellationToken.None); + + _policyRepo.Verify(x => x.TryTransitionStateAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task Paused_queue_opens_no_windows_but_liveness_still_runs() + { + DataProtectionConfig.MigrationQueuePaused = true; + SetupPolicies(Policy(DepartmentDataProtectionState.EnrollmentQueued)); + + var result = await _logic.Process(CancellationToken.None); + + result.Item2.Should().Contain("paused"); + _lockService.Verify(x => x.ReleaseExpiredLocksAsync(It.IsAny()), Times.Once); + _lockService.Verify(x => x.ApplyLockAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Closed_window_defers_the_department() + { + SetupPolicies(Policy(DepartmentDataProtectionState.EnrollmentQueued, windowAlwaysOpen: false)); + + await _logic.Process(CancellationToken.None); + + _lockService.Verify(x => x.ApplyLockAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Failed_state_is_not_auto_resumed() + { + SetupPolicies(Policy(DepartmentDataProtectionState.Failed, DepartmentDataProtectionMigrationKind.Enrollment)); + + await _logic.Process(CancellationToken.None); + + _lockService.Verify(x => x.ApplyLockAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + #endregion + + #region Night execution + + [Test] + public async Task Full_enrollment_night_reaches_enabled_with_verified_engine() + { + SetupPolicies(Policy(DepartmentDataProtectionState.EnrollmentQueued)); + _engine.Setup(x => x.RunEncryptionNightAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(AdpMigrationNightResult.Completed(100)); + _engine.Setup(x => x.VerifyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var result = await _logic.Process(CancellationToken.None); + + result.Item2.Should().Contain("protection active"); + _keyService.Verify(x => x.ProvisionNextKeyVersionAsync(DeptId, It.IsAny()), Times.Once); + _policyRepo.Verify(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.Verifying, + DepartmentDataProtectionState.Enabled, null, It.IsAny(), It.IsAny()), Times.Once); + _protectionService.Verify(x => x.IncrementPolicyEpochAsync(DeptId, It.IsAny(), It.IsAny()), Times.Once); + _lockService.Verify(x => x.ReleaseLockAsync(11, DepartmentOperationLockReleaseKind.Completed, + It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Window_close_checkpoints_and_releases_the_lock() + { + SetupPolicies(Policy(DepartmentDataProtectionState.Encrypting, DepartmentDataProtectionMigrationKind.Enrollment)); + _keyService.Setup(x => x.GetActiveKeyAsync(DeptId)) + .ReturnsAsync(new DepartmentDataProtectionKey { Version = 1 }); + _engine.Setup(x => x.RunEncryptionNightAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(AdpMigrationNightResult.WindowClosed(5000, 40)); + + var result = await _logic.Process(CancellationToken.None); + + result.Item2.Should().Contain("checkpointed"); + _policyRepo.Verify(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.Encrypting, + DepartmentDataProtectionState.Verifying, It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _lockService.Verify(x => x.ReleaseLockAsync(11, DepartmentOperationLockReleaseKind.Checkpoint, + It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Engine_failure_marks_failed_and_releases_as_aborted() + { + SetupPolicies(Policy(DepartmentDataProtectionState.Encrypting, DepartmentDataProtectionMigrationKind.Enrollment)); + _keyService.Setup(x => x.GetActiveKeyAsync(DeptId)) + .ReturnsAsync(new DepartmentDataProtectionKey { Version = 1 }); + _engine.Setup(x => x.RunEncryptionNightAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(AdpMigrationNightResult.Failed("batch_error")); + + var result = await _logic.Process(CancellationToken.None); + + result.Item2.Should().Contain("failed"); + _policyRepo.Verify(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.Encrypting, + DepartmentDataProtectionState.Failed, (int)DepartmentDataProtectionMigrationKind.Enrollment, + It.IsAny(), It.IsAny()), Times.Once); + _lockService.Verify(x => x.ReleaseLockAsync(11, DepartmentOperationLockReleaseKind.Aborted, + It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Unavailable_engine_skips_nights_and_never_fails_queued_departments() + { + // A host without a real KMS adapter (Null engine / NotConfigured provider) must leave + // queued work QUEUED for the broker — never open a window destined to fail. + SetupPolicies(Policy(DepartmentDataProtectionState.Encrypting, DepartmentDataProtectionMigrationKind.Enrollment)); + _keyService.Setup(x => x.GetActiveKeyAsync(DeptId)) + .ReturnsAsync(new DepartmentDataProtectionKey { Version = 1 }); + + var logic = new AdpMigrationLogic(_lockService.Object, _policyRepo.Object, _protectionService.Object, + _keyService.Object, new NullDepartmentDataMigrationEngine(), new ProtectedFieldCatalog(), + _departmentsService.Object, _emailService.Object); + + var result = await logic.Process(CancellationToken.None); + + result.Item1.Should().BeTrue(); + result.Item2.Should().Contain("engine unavailable"); + _policyRepo.Verify(x => x.TryTransitionStateAsync(It.IsAny(), It.IsAny(), + DepartmentDataProtectionState.Failed, It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _lockService.Verify(x => x.ApplyLockAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Unavailable_engine_still_runs_liveness_and_offboarding_flips() + { + var offboarding = Policy(DepartmentDataProtectionState.OffboardingScheduled, windowAlwaysOpen: false); + offboarding.OffboardingEffectiveOn = DateTime.UtcNow.AddDays(-1); + SetupPolicies(offboarding); + + var logic = new AdpMigrationLogic(_lockService.Object, _policyRepo.Object, _protectionService.Object, + _keyService.Object, new NullDepartmentDataMigrationEngine(), new ProtectedFieldCatalog(), + _departmentsService.Object, _emailService.Object); + + var result = await logic.Process(CancellationToken.None); + + result.Item1.Should().BeTrue(); + _policyRepo.Verify(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.OffboardingScheduled, + DepartmentDataProtectionState.DisableRequested, (int)DepartmentDataProtectionMigrationKind.Offboarding, + It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Offboarding_night_reaches_disabled_with_verified_engine() + { + SetupPolicies(Policy(DepartmentDataProtectionState.DisableRequested, DepartmentDataProtectionMigrationKind.Offboarding)); + _engine.Setup(x => x.RunDecryptionNightAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(AdpMigrationNightResult.Completed(100)); + _engine.Setup(x => x.VerifyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var result = await _logic.Process(CancellationToken.None); + + result.Item2.Should().Contain("offboarding complete"); + _policyRepo.Verify(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.Verifying, + DepartmentDataProtectionState.Disabled, null, It.IsAny(), It.IsAny()), Times.Once); + _keyService.Verify(x => x.ProvisionNextKeyVersionAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Lock_contention_skips_the_department() + { + SetupPolicies(Policy(DepartmentDataProtectionState.EnrollmentQueued)); + _lockService.Setup(x => x.ApplyLockAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentOperationLock)null); + + var result = await _logic.Process(CancellationToken.None); + + result.Item2.Should().Contain("lock unavailable"); + _engine.Verify(x => x.RunEncryptionNightAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Nightly_concurrency_caps_departments_per_sweep() + { + DataProtectionConfig.MigrationNightlyConcurrency = 1; + var first = Policy(DepartmentDataProtectionState.EnrollmentQueued); + var second = Policy(DepartmentDataProtectionState.EnrollmentQueued); + second.DepartmentDataProtectionPolicyId = 2; + second.DepartmentId = DeptId + 1; + second.CreatedOn = DateTime.UtcNow; + SetupPolicies(first, second); + _engine.Setup(x => x.RunEncryptionNightAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(AdpMigrationNightResult.WindowClosed(10, 1)); + + await _logic.Process(CancellationToken.None); + + _lockService.Verify(x => x.ApplyLockAsync(DeptId, It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); + _lockService.Verify(x => x.ApplyLockAsync(DeptId + 1, It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + + #endregion + + #region Window math + + [Test] + public void Overnight_window_spanning_midnight_is_open_on_both_sides() + { + var policy = new DepartmentDataProtectionPolicy + { + DepartmentId = DeptId, + MigrationWindowStartLocal = "22:00", + MigrationWindowEndLocal = "06:00", + MigrationWindowTimeZone = "UTC" + }; + + AdpMigrationLogic.TryGetOpenWindow(policy, new DateTime(2026, 8, 27, 23, 0, 0, DateTimeKind.Utc), out var endLate) + .Should().BeTrue(); + endLate.Should().Be(new DateTime(2026, 8, 28, 6, 0, 0, DateTimeKind.Utc)); + + AdpMigrationLogic.TryGetOpenWindow(policy, new DateTime(2026, 8, 28, 2, 0, 0, DateTimeKind.Utc), out var endEarly) + .Should().BeTrue(); + endEarly.Should().Be(new DateTime(2026, 8, 28, 6, 0, 0, DateTimeKind.Utc)); + + AdpMigrationLogic.TryGetOpenWindow(policy, new DateTime(2026, 8, 27, 12, 0, 0, DateTimeKind.Utc), out _) + .Should().BeFalse(); + } + + [Test] + public void Twenty_four_hour_window_has_no_hole_at_day_end() + { + // Regression: the fixture's always-open window used a "23:59" end, and the exclusive + // end check closed it for the last minute of the UTC day — CI runs landing in that + // minute failed every night-execution test. + var policy = Policy(DepartmentDataProtectionState.EnrollmentQueued); + + AdpMigrationLogic.TryGetOpenWindow(policy, new DateTime(2026, 8, 27, 23, 59, 30, DateTimeKind.Utc), out _) + .Should().BeTrue(); + AdpMigrationLogic.TryGetOpenWindow(policy, new DateTime(2026, 8, 28, 0, 0, 0, DateTimeKind.Utc), out _) + .Should().BeTrue(); + + var closed = Policy(DepartmentDataProtectionState.EnrollmentQueued, windowAlwaysOpen: false); + AdpMigrationLogic.TryGetOpenWindow(closed, new DateTime(2026, 8, 27, 3, 0, 30, DateTimeKind.Utc), out _) + .Should().BeFalse("an equal start and end must be closed even inside its own minute"); + } + + [Test] + public void Missing_or_bogus_time_zone_reads_as_closed() + { + var noZone = new DepartmentDataProtectionPolicy + { + DepartmentId = DeptId, + MigrationWindowStartLocal = "00:00", + MigrationWindowEndLocal = "23:59", + MigrationWindowTimeZone = null + }; + AdpMigrationLogic.TryGetOpenWindow(noZone, DateTime.UtcNow, out _).Should().BeFalse(); + + var badZone = new DepartmentDataProtectionPolicy + { + DepartmentId = DeptId, + MigrationWindowStartLocal = "00:00", + MigrationWindowEndLocal = "23:59", + MigrationWindowTimeZone = "Not/AZone" + }; + AdpMigrationLogic.TryGetOpenWindow(badZone, DateTime.UtcNow, out _).Should().BeFalse(); + } + + #endregion + } +} diff --git a/Web/Resgrid.Web.Broker/Controllers/BrokerController.cs b/Web/Resgrid.Web.Broker/Controllers/BrokerController.cs new file mode 100644 index 000000000..52d75e75b --- /dev/null +++ b/Web/Resgrid.Web.Broker/Controllers/BrokerController.cs @@ -0,0 +1,71 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model.Providers; +using Resgrid.Web.Broker.Models; +using Resgrid.Web.Broker.Services; + +namespace Resgrid.Web.Broker.Controllers +{ + /// + /// Field-crypto endpoints for the application tier (behind WorkloadKeyMiddleware). The response + /// body is always a ProtectedDataBrokerResult; the HTTP status mirrors its error code so plain + /// HTTP clients and infrastructure see failures too. No endpoint here exposes key material, a + /// general unwrap, or any bulk/no-grant path. + /// + [ApiController] + [Route("api/v1/broker")] + public class BrokerController : ControllerBase + { + private readonly BrokerOperationService _operationService; + + public BrokerController(BrokerOperationService operationService) + { + _operationService = operationService; + } + + [HttpPost("decrypt")] + public async Task> Decrypt([FromBody] BrokerFieldOperationRequest request, + CancellationToken cancellationToken) + { + var result = await _operationService.DecryptAsync(request, cancellationToken); + return StatusCode(MapStatusCode(result), result); + } + + [HttpPost("encrypt")] + public async Task> Encrypt([FromBody] BrokerFieldOperationRequest request, + CancellationToken cancellationToken) + { + var result = await _operationService.EncryptAsync(request, cancellationToken); + return StatusCode(MapStatusCode(result), result); + } + + private static int MapStatusCode(ProtectedDataBrokerResult result) + { + if (result.Success) + return StatusCodes.Status200OK; + + switch (result.ErrorCode) + { + case "invalid_request": + return StatusCodes.Status400BadRequest; + case "too_many_items": + return StatusCodes.Status413PayloadTooLarge; + case "replayed_request": + return StatusCodes.Status409Conflict; + case "grant_expired": + case "grant_invalid": + return StatusCodes.Status401Unauthorized; + case "grant_revoked": + return StatusCodes.Status403Forbidden; + case "no_active_key": + return StatusCodes.Status409Conflict; + default: + // grant_validation_unavailable, kms_unavailable and anything unmapped fail closed + // as a service fault. + return StatusCodes.Status503ServiceUnavailable; + } + } + } +} diff --git a/Web/Resgrid.Web.Broker/Dockerfile b/Web/Resgrid.Web.Broker/Dockerfile new file mode 100644 index 000000000..0891acd9d --- /dev/null +++ b/Web/Resgrid.Web.Broker/Dockerfile @@ -0,0 +1,73 @@ +# syntax=docker/dockerfile:1.7 +# Protected Data Broker — the ONLY image with a KMS route. Deploy on an isolated subnet; +# the broker's OpenBao client certificate is a mounted secret, never baked into this image. +ARG BUILD_VERSION=3.5.0 + +FROM dhi.io/aspnetcore:9.0.16-debian13@sha256:961647e80202ce33fc06472dda4e7ae2d2bc56d819aee6742602f70047b13dc7 AS base +ARG BUILD_VERSION +WORKDIR /app +EXPOSE 80 + +FROM dhi.io/dotnet:9.0.314-sdk-debian13@sha256:a3acd51de0af79878e26292b3053aab513c6ca4476ddb2f9f11adb9c04aa7c89 AS build +ARG BUILD_VERSION +WORKDIR /src +## Root MSBuild files must be present for the restore layer, otherwise the +## security pins in Directory.Build.targets are missing from project.assets.json. +COPY ["Directory.Build.props", "./"] +COPY ["Directory.Build.targets", "./"] +## Every project transitively reachable from Resgrid.Web.Broker.csproj (29) must be present +## for the restore layer — a missing ProjectReference fails MSBuild evaluation here. +COPY ["Core/Resgrid.Chatbot.NLU/Resgrid.Chatbot.NLU.csproj", "Core/Resgrid.Chatbot.NLU/"] +COPY ["Core/Resgrid.Chatbot/Resgrid.Chatbot.csproj", "Core/Resgrid.Chatbot/"] +COPY ["Core/Resgrid.Config/Resgrid.Config.csproj", "Core/Resgrid.Config/"] +COPY ["Core/Resgrid.Framework/Resgrid.Framework.csproj", "Core/Resgrid.Framework/"] +COPY ["Core/Resgrid.Localization/Resgrid.Localization.csproj", "Core/Resgrid.Localization/"] +COPY ["Core/Resgrid.Model/Resgrid.Model.csproj", "Core/Resgrid.Model/"] +COPY ["Core/Resgrid.Services/Resgrid.Services.csproj", "Core/Resgrid.Services/"] +COPY ["Providers/Resgrid.Providers.AddressVerification/Resgrid.Providers.AddressVerification.csproj", "Providers/Resgrid.Providers.AddressVerification/"] +COPY ["Providers/Resgrid.Providers.Bus.Rabbit/Resgrid.Providers.Bus.Rabbit.csproj", "Providers/Resgrid.Providers.Bus.Rabbit/"] +COPY ["Providers/Resgrid.Providers.Bus/Resgrid.Providers.Bus.csproj", "Providers/Resgrid.Providers.Bus/"] +COPY ["Providers/Resgrid.Providers.Cache/Resgrid.Providers.Cache.csproj", "Providers/Resgrid.Providers.Cache/"] +COPY ["Providers/Resgrid.Providers.Chatbot/Resgrid.Providers.Chatbot.csproj", "Providers/Resgrid.Providers.Chatbot/"] +COPY ["Providers/Resgrid.Providers.Claims/Resgrid.Providers.Claims.csproj", "Providers/Resgrid.Providers.Claims/"] +COPY ["Providers/Resgrid.Providers.Email/Resgrid.Providers.Email.csproj", "Providers/Resgrid.Providers.Email/"] +COPY ["Providers/Resgrid.Providers.Geo/Resgrid.Providers.Geo.csproj", "Providers/Resgrid.Providers.Geo/"] +COPY ["Providers/Resgrid.Providers.Marketing/Resgrid.Providers.Marketing.csproj", "Providers/Resgrid.Providers.Marketing/"] +COPY ["Providers/Resgrid.Providers.Messaging/Resgrid.Providers.Messaging.csproj", "Providers/Resgrid.Providers.Messaging/"] +COPY ["Providers/Resgrid.Providers.Migrations/Resgrid.Providers.Migrations.csproj", "Providers/Resgrid.Providers.Migrations/"] +COPY ["Providers/Resgrid.Providers.MigrationsPg/Resgrid.Providers.MigrationsPg.csproj", "Providers/Resgrid.Providers.MigrationsPg/"] +COPY ["Providers/Resgrid.Providers.Number/Resgrid.Providers.Number.csproj", "Providers/Resgrid.Providers.Number/"] +COPY ["Providers/Resgrid.Providers.Pdf/Resgrid.Providers.Pdf.csproj", "Providers/Resgrid.Providers.Pdf/"] +COPY ["Providers/Resgrid.Providers.ProtectedData/Resgrid.Providers.ProtectedData.csproj", "Providers/Resgrid.Providers.ProtectedData/"] +COPY ["Providers/Resgrid.Providers.Voip/Resgrid.Providers.Voip.csproj", "Providers/Resgrid.Providers.Voip/"] +COPY ["Providers/Resgrid.Providers.Weather/Resgrid.Providers.Weather.csproj", "Providers/Resgrid.Providers.Weather/"] +COPY ["Providers/Resgrid.Providers.Workflow/Resgrid.Providers.Workflow.csproj", "Providers/Resgrid.Providers.Workflow/"] +COPY ["Repositories/Resgrid.Repositories.DataRepository/Resgrid.Repositories.DataRepository.csproj", "Repositories/Resgrid.Repositories.DataRepository/"] +COPY ["Repositories/Resgrid.Repositories.NoSqlRepository/Resgrid.Repositories.NoSqlRepository.csproj", "Repositories/Resgrid.Repositories.NoSqlRepository/"] +COPY ["Web/Resgrid.Web.Broker/Resgrid.Web.Broker.csproj", "Web/Resgrid.Web.Broker/"] +COPY ["Workers/Resgrid.Workers.Framework/Resgrid.Workers.Framework.csproj", "Workers/Resgrid.Workers.Framework/"] +RUN dotnet restore "Web/Resgrid.Web.Broker/Resgrid.Web.Broker.csproj" +COPY . . +WORKDIR "/src/Web/Resgrid.Web.Broker" + +FROM build AS publish +ARG BUILD_VERSION +## The hardened DHI image marks tzdata as installed (dpkg) but ships none of the +## zone files (see the Eventing Dockerfile note); the migration window scheduler +## needs real zone files, so fail the build loudly if they are missing. +RUN DEBIAN_FRONTEND=noninteractive apt-get update \ + && apt-get install -y --reinstall --no-install-recommends tzdata \ + && test -f /usr/share/zoneinfo/America/New_York \ + && test -f /usr/share/zoneinfo/Asia/Kolkata \ + && rm -rf /var/lib/apt/lists/* +RUN dotnet publish "Resgrid.Web.Broker.csproj" -c Release -o /app/publish -p:Version=${BUILD_VERSION} +ADD --checksum=sha256:2241be671073520e028b2f12df1e9ef0419014cffb5670b7a80b2080804be17d https://github.com/ufoscout/docker-compose-wait/releases/download/2.12.1/wait /app/publish/wait +RUN chmod +x /app/publish/wait + +FROM base AS final +WORKDIR /app +COPY --from=publish /usr/share/zoneinfo /usr/share/zoneinfo +ENV TZ=Etc/UTC +COPY --from=publish /app/publish . +ENV WAIT_COMMAND="dotnet Resgrid.Web.Broker.dll" +ENTRYPOINT ["./wait"] diff --git a/Web/Resgrid.Web.Broker/Middleware/WorkloadKeyMiddleware.cs b/Web/Resgrid.Web.Broker/Middleware/WorkloadKeyMiddleware.cs new file mode 100644 index 000000000..b575ee06b --- /dev/null +++ b/Web/Resgrid.Web.Broker/Middleware/WorkloadKeyMiddleware.cs @@ -0,0 +1,58 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Resgrid.Config; + +namespace Resgrid.Web.Broker.Middleware +{ + /// + /// Application-tier workload gate (ADP plan section 2.2): every broker API request must present + /// the shared workload key in X-Resgrid-Broker-Key. This is defense-in-depth UNDER network + /// isolation and transport-level mTLS, never the only control. An unconfigured key refuses + /// everything (503, fail closed); a wrong key is 401 with no detail. Comparison is + /// constant-time. /health is exempt for the k8s probes. + /// + public class WorkloadKeyMiddleware + { + private readonly RequestDelegate _next; + + public WorkloadKeyMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Request.Path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase)) + { + await _next(context); + return; + } + + var configuredKey = DataProtectionConfig.BrokerApiKey; + if (string.IsNullOrWhiteSpace(configuredKey)) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + return; + } + + var presentedKey = context.Request.Headers["X-Resgrid-Broker-Key"].ToString(); + if (string.IsNullOrEmpty(presentedKey) || !FixedTimeEquals(presentedKey, configuredKey)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + await _next(context); + } + + private static bool FixedTimeEquals(string presented, string configured) + { + var presentedBytes = Encoding.UTF8.GetBytes(presented); + var configuredBytes = Encoding.UTF8.GetBytes(configured); + return CryptographicOperations.FixedTimeEquals(presentedBytes, configuredBytes); + } + } +} diff --git a/Web/Resgrid.Web.Broker/Models/BrokerFieldOperationRequest.cs b/Web/Resgrid.Web.Broker/Models/BrokerFieldOperationRequest.cs new file mode 100644 index 000000000..5f86b2f33 --- /dev/null +++ b/Web/Resgrid.Web.Broker/Models/BrokerFieldOperationRequest.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using Resgrid.Model.Providers; + +namespace Resgrid.Web.Broker.Models +{ + /// + /// One broker field-crypto request (decrypt or encrypt). The department here is CHECKED against + /// the grant's dept claim and each envelope's AAD — a conflicting value fails, it never selects + /// a tenant (plan section 2.2). RequestId is single-use per department (replay control). + /// + public class BrokerFieldOperationRequest + { + public int DepartmentId { get; set; } + + /// The caller's Protected Data Grant token (compact JWS). + public string GrantToken { get; set; } + + /// Caller-generated unique id for this request; replays are refused. + public string RequestId { get; set; } + + public List Items { get; set; } + } +} diff --git a/Web/Resgrid.Web.Broker/Program.cs b/Web/Resgrid.Web.Broker/Program.cs new file mode 100644 index 000000000..b373d2872 --- /dev/null +++ b/Web/Resgrid.Web.Broker/Program.cs @@ -0,0 +1,56 @@ +using System.Reflection; +using Autofac.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Resgrid.Config; + +namespace Resgrid.Web.Broker +{ + /// + /// Protected Data Broker host (ADP plan sections 2.1-2.2): the only deployable with a KMS route + /// and a real IKeyWrappingProvider. Deploy it on an isolated subnet — application/worker hosts + /// may reach the broker's HTTP endpoints; nothing but the broker reaches OpenBao. Never + /// co-locate this process with Web, API, worker, or BackOffice hosts. + /// + public class Program + { + public static void Main(string[] args) + { + CreateHostBuilder(args).Build().Run(); + } + + public static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .UseServiceProviderFactory(new AutofacServiceProviderFactory()) + .ConfigureAppConfiguration((hostingContext, config) => + { + config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); + config.AddEnvironmentVariables(); + + var built = config.Build(); + ConfigProcessor.LoadAndProcessConfig(built["AppOptions:ConfigPath"]); + ConfigProcessor.LoadAndProcessEnvVariables(built.AsEnumerable()); + }) + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseStartup(); + + if (!string.IsNullOrWhiteSpace(ExternalErrorConfig.ExternalErrorServiceUrlForBroker)) + { + webBuilder.UseSentry(options => + { + options.Dsn = ExternalErrorConfig.ExternalErrorServiceUrlForBroker; + options.AttachStacktrace = true; + // This host decrypts protected values: keep request bodies and PII out of + // telemetry entirely. + options.SendDefaultPii = false; + options.MaxRequestBodySize = Sentry.Extensibility.RequestSize.None; + options.TracesSampleRate = ExternalErrorConfig.SentryPerfSampleRate; + options.Environment = ExternalErrorConfig.Environment; + options.Release = Assembly.GetEntryAssembly()?.GetName().Version?.ToString(); + }); + } + }); + } +} diff --git a/Web/Resgrid.Web.Broker/Resgrid.Web.Broker.csproj b/Web/Resgrid.Web.Broker/Resgrid.Web.Broker.csproj new file mode 100644 index 000000000..4067cfa4f --- /dev/null +++ b/Web/Resgrid.Web.Broker/Resgrid.Web.Broker.csproj @@ -0,0 +1,32 @@ + + + net9.0 + Resgrid Protected Data Broker: validates Protected Data Grants, performs ADP field crypto against the KMS, and hosts the ADP migration engine. The ONLY host with a KMS route. + Resgrid.Web.Broker + Resgrid.Web.Broker + Linux + ..\.. + Debug;Release;Docker + Resgrid.Web.Broker.Program + + + True + + + + + + + + + + + + + + + + + + + diff --git a/Web/Resgrid.Web.Broker/Services/AdpMigrationSweepService.cs b/Web/Resgrid.Web.Broker/Services/AdpMigrationSweepService.cs new file mode 100644 index 000000000..7c30f54c8 --- /dev/null +++ b/Web/Resgrid.Web.Broker/Services/AdpMigrationSweepService.cs @@ -0,0 +1,82 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Microsoft.Extensions.Hosting; +using Resgrid.Framework; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Web.Broker.Services +{ + /// + /// Hosts the ADP migration coordinator on the broker — the only process whose engine can + /// actually move data, because only the broker resolves a real IKeyWrappingProvider (plan + /// sections 2.2 and 19; deviation from 19.1's separate deployable recorded in the coordinator). + /// Workers.Console keeps its scheduled sweep for lock liveness and offboarding flips, but its + /// engine reports unavailable there, so nights run exclusively here. Disable with + /// DataProtectionConfig.BrokerRunsMigrations=false when ops later splits a dedicated migration + /// deployable off the broker. + /// + public class AdpMigrationSweepService : BackgroundService + { + private readonly ILifetimeScope _rootScope; + + public AdpMigrationSweepService(ILifetimeScope rootScope) + { + _rootScope = rootScope; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!Config.DataProtectionConfig.BrokerRunsMigrations) + { + Logging.LogInfo("ADP migration sweep is disabled on this broker (DataProtectionConfig.BrokerRunsMigrations=false)."); + return; + } + + var interval = TimeSpan.FromSeconds(Math.Max(60, Config.DataProtectionConfig.BrokerMigrationSweepSeconds)); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + // A fresh scope per sweep: the coordinator's per-lifetime-scope dependencies + // (repositories, services, the engine with the real KMS provider) live and die + // with the sweep. + using var scope = _rootScope.BeginLifetimeScope(); + var logic = new AdpMigrationLogic( + scope.Resolve(), + scope.Resolve(), + scope.Resolve(), + scope.Resolve(), + scope.Resolve(), + scope.Resolve(), + scope.Resolve(), + scope.Resolve()); + + var result = await logic.Process(stoppingToken); + Logging.LogInfo($"ADP broker migration sweep: {result.Item2}"); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + Logging.LogException(ex, "ADP broker migration sweep failed; next sweep continues on schedule."); + } + + try + { + await Task.Delay(interval, stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + } + } + } +} diff --git a/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs b/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs new file mode 100644 index 000000000..f08bf8fe3 --- /dev/null +++ b/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs @@ -0,0 +1,266 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Microsoft.Extensions.Caching.Memory; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Web.Broker.Models; + +namespace Resgrid.Web.Broker.Services +{ + /// + /// The broker's field-crypto pipeline (ADP plan section 3.1 steps 8-9): validate the grant + /// against the department's CURRENT policy epoch, refuse replayed request ids, unwrap the + /// referenced DEK versions once per request, run AEAD field crypto with full AAD binding, zero + /// key material, and emit a value-free audit line. Every failure is closed — a request-level + /// fault processes NO items, and item-level faults return error codes, never partial values. + /// Plaintext and ciphertext values are never logged. + /// + public class BrokerOperationService + { + // Replayed request ids are refused for this long; grants outlive it, so a replayed id can + // never slip back in while its grant is still valid. + private static readonly TimeSpan ReplayWindow = TimeSpan.FromMinutes(15); + + private readonly ILifetimeScope _rootScope; + private readonly IProtectedDataGrantService _grantService; + private readonly IProtectedFieldCryptoService _cryptoService; + private readonly IKeyWrappingProvider _keyWrappingProvider; + private readonly IMemoryCache _replayCache; + + public BrokerOperationService(ILifetimeScope rootScope, IProtectedDataGrantService grantService, + IProtectedFieldCryptoService cryptoService, IKeyWrappingProvider keyWrappingProvider, + IMemoryCache replayCache) + { + _rootScope = rootScope; + _grantService = grantService; + _cryptoService = cryptoService; + _keyWrappingProvider = keyWrappingProvider; + _replayCache = replayCache; + } + + public Task DecryptAsync(BrokerFieldOperationRequest request, CancellationToken cancellationToken) => + ProcessAsync(request, decrypt: true, cancellationToken); + + public Task EncryptAsync(BrokerFieldOperationRequest request, CancellationToken cancellationToken) => + ProcessAsync(request, decrypt: false, cancellationToken); + + private async Task ProcessAsync(BrokerFieldOperationRequest request, bool decrypt, + CancellationToken cancellationToken) + { + if (request == null || request.DepartmentId <= 0 || string.IsNullOrWhiteSpace(request.RequestId) || + request.Items == null || request.Items.Count == 0) + return Fail("invalid_request"); + + var maxItems = Math.Max(1, Config.DataProtectionConfig.BrokerMaxItemsPerRequest); + if (request.Items.Count > maxItems) + return Fail("too_many_items"); + + // Replay: a request id is single-use per department (plan section 2.2). + var replayKey = $"adp-broker-request:{request.DepartmentId}:{request.RequestId}"; + if (!TryClaimRequestId(replayKey)) + return Fail("replayed_request"); + + using var scope = _rootScope.BeginLifetimeScope(); + var policyRepository = scope.Resolve(); + var keyService = scope.Resolve(); + + // Current policy epoch straight from the database — a bump anywhere revokes here now. + var policy = await policyRepository.GetByDepartmentIdAsync(request.DepartmentId); + var currentEpoch = policy?.PolicyEpoch ?? 0; + + var requiredScope = decrypt ? ProtectedDataGrantScopes.Read : ProtectedDataGrantScopes.Write; + var outcome = _grantService.ValidateGrant(request.GrantToken, request.DepartmentId, currentEpoch, + requiredScope, out var grant); + if (outcome != ProtectedDataGrantValidationOutcome.Valid) + return Fail(MapGrantOutcome(outcome)); + + var result = new ProtectedDataBrokerResult { Success = true }; + var unwrappedKeys = new Dictionary(); + try + { + if (decrypt) + await DecryptItemsAsync(request, keyService, unwrappedKeys, result, cancellationToken); + else + await EncryptItemsAsync(request, keyService, unwrappedKeys, result, cancellationToken); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + // KMS unreachable or another crypto-path fault: the WHOLE request fails closed. + Logging.LogError($"ADP broker {(decrypt ? "decrypt" : "encrypt")} failed closed for department {request.DepartmentId}: {ex.GetType().Name}."); + return Fail("kms_unavailable"); + } + finally + { + foreach (var dek in unwrappedKeys.Values) + CryptographicOperations.ZeroMemory(dek); + } + + Audit(decrypt ? "decrypt" : "encrypt", request, grant, result); + return result; + } + + private async Task DecryptItemsAsync(BrokerFieldOperationRequest request, IDepartmentKeyService keyService, + Dictionary unwrappedKeys, ProtectedDataBrokerResult result, CancellationToken cancellationToken) + { + foreach (var item in request.Items) + { + cancellationToken.ThrowIfCancellationRequested(); + + var itemResult = new ProtectedFieldOperationResult { FieldId = item?.FieldId, RowKey = item?.RowKey }; + result.Items.Add(itemResult); + + if (item == null || string.IsNullOrWhiteSpace(item.FieldId) || string.IsNullOrWhiteSpace(item.RowKey)) + { + itemResult.ErrorCode = "invalid_item"; + continue; + } + + if (!ProtectedDataEnvelope.TryParse(item.Value, out var formatVersion, out var keyVersion, out _) || + formatVersion > ProtectedDataEnvelope.CurrentVersion) + { + // Plaintext or corrupt: the broker never echoes the input back — the caller + // already holds it, and an unparseable prefixed value must read as corrupt. + itemResult.ErrorCode = ProtectedDataEnvelope.HasEnvelopePrefix(item.Value) + ? "envelope_malformed" + : "not_enveloped"; + continue; + } + + var dek = await ResolveKeyAsync(request.DepartmentId, keyVersion, keyService, unwrappedKeys, cancellationToken); + if (dek == null) + { + itemResult.ErrorCode = "key_unknown"; + continue; + } + + try + { + itemResult.Value = _cryptoService.DecryptText(dek, item.Value, request.DepartmentId, + item.FieldId, item.RowKey, item.CatalogVersion); + } + catch (Exception ex) when (ex is CryptographicException || ex is FormatException || ex is ArgumentException) + { + // AAD mismatch (foreign/moved ciphertext) or malformed payload — value-free. + itemResult.ErrorCode = "decrypt_failed"; + } + } + } + + private async Task EncryptItemsAsync(BrokerFieldOperationRequest request, IDepartmentKeyService keyService, + Dictionary unwrappedKeys, ProtectedDataBrokerResult result, CancellationToken cancellationToken) + { + var activeKey = await keyService.GetActiveKeyAsync(request.DepartmentId); + if (activeKey == null) + { + result.Success = false; + result.ErrorCode = "no_active_key"; + result.Items.Clear(); + return; + } + + foreach (var item in request.Items) + { + cancellationToken.ThrowIfCancellationRequested(); + + var itemResult = new ProtectedFieldOperationResult { FieldId = item?.FieldId, RowKey = item?.RowKey }; + result.Items.Add(itemResult); + + if (item == null || string.IsNullOrWhiteSpace(item.FieldId) || string.IsNullOrWhiteSpace(item.RowKey) || + item.Value == null) + { + itemResult.ErrorCode = "invalid_item"; + continue; + } + + if (ProtectedDataEnvelope.HasEnvelopePrefix(item.Value)) + { + // Double-encryption guard: enveloped input reaching an encrypt call is a caller + // bug, never something to encrypt again. + itemResult.ErrorCode = "already_enveloped"; + continue; + } + + var dek = await ResolveKeyAsync(request.DepartmentId, activeKey.Version, keyService, unwrappedKeys, cancellationToken); + if (dek == null) + { + itemResult.ErrorCode = "key_unknown"; + continue; + } + + try + { + itemResult.Value = _cryptoService.EncryptText(dek, activeKey.Version, item.Value, + request.DepartmentId, item.FieldId, item.RowKey, item.CatalogVersion); + } + catch (Exception ex) when (ex is CryptographicException || ex is ArgumentException || ex is InvalidOperationException) + { + itemResult.ErrorCode = "encrypt_failed"; + } + } + } + + /// Unwraps each referenced key version once per request; null when the version is unknown. + private async Task ResolveKeyAsync(int departmentId, int keyVersion, IDepartmentKeyService keyService, + Dictionary unwrappedKeys, CancellationToken cancellationToken) + { + if (unwrappedKeys.TryGetValue(keyVersion, out var cached)) + return cached; + + var keyRow = await keyService.GetKeyByVersionAsync(departmentId, keyVersion); + if (keyRow == null || string.IsNullOrWhiteSpace(keyRow.WrappedKey)) + return null; + + // The provider returns the DEK in pinned memory; ProcessAsync zeroes it in its finally. + var dek = await _keyWrappingProvider.UnwrapDataKeyAsync(departmentId, keyRow.WrappedKey, cancellationToken); + unwrappedKeys[keyVersion] = dek; + return dek; + } + + private bool TryClaimRequestId(string replayKey) + { + lock (_replayCache) + { + if (_replayCache.TryGetValue(replayKey, out _)) + return false; + + _replayCache.Set(replayKey, true, ReplayWindow); + return true; + } + } + + private static string MapGrantOutcome(ProtectedDataGrantValidationOutcome outcome) + { + switch (outcome) + { + case ProtectedDataGrantValidationOutcome.NotConfigured: + return "grant_validation_unavailable"; + case ProtectedDataGrantValidationOutcome.Expired: + return "grant_expired"; + case ProtectedDataGrantValidationOutcome.EpochRevoked: + return "grant_revoked"; + default: + return "grant_invalid"; + } + } + + private static ProtectedDataBrokerResult Fail(string errorCode) => + new ProtectedDataBrokerResult { Success = false, ErrorCode = errorCode }; + + /// Value-free audit line: identifiers and counts only, never field values. + private static void Audit(string operation, BrokerFieldOperationRequest request, ProtectedDataGrant grant, + ProtectedDataBrokerResult result) + { + var failed = result.Items.Count(i => i.ErrorCode != null); + var fields = string.Join(",", request.Items.Where(i => i?.FieldId != null).Select(i => i.FieldId).Distinct()); + Logging.LogInfo($"ADP broker {operation}: department {request.DepartmentId}, user {grant.UserId}, grant {grant.GrantId}, request {request.RequestId}, items {result.Items.Count}, failed {failed}, fields [{fields}]"); + } + } +} diff --git a/Web/Resgrid.Web.Broker/Startup.cs b/Web/Resgrid.Web.Broker/Startup.cs new file mode 100644 index 000000000..ab8a0f4f1 --- /dev/null +++ b/Web/Resgrid.Web.Broker/Startup.cs @@ -0,0 +1,151 @@ +using System; +using System.Configuration; +using System.Reflection; +using Autofac; +using Autofac.Extensions.DependencyInjection; +using Autofac.Extras.CommonServiceLocator; +using CommonServiceLocator; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Resgrid.Config; +using Resgrid.Model.Providers; +using Resgrid.Providers.AddressVerification; +using Resgrid.Providers.Bus; +using Resgrid.Providers.Bus.Rabbit; +using Resgrid.Providers.Cache; +using Resgrid.Providers.EmailProvider; +using Resgrid.Providers.GeoLocationProvider; +using Resgrid.Providers.Marketing; +using Resgrid.Providers.Messaging; +using Resgrid.Providers.NumberProvider; +using Resgrid.Providers.PdfProvider; +using Resgrid.Providers.ProtectedData; +using Resgrid.Repositories.DataRepository; +using Resgrid.Services; +using Resgrid.Web.Broker.Services; + +namespace Resgrid.Web.Broker +{ + public class Startup + { + public IConfiguration Configuration { get; } + + public ILifetimeScope AutofacContainer { get; private set; } + public AutofacServiceLocator Locator { get; private set; } + + public Startup(IWebHostEnvironment env) + { + var builder = new ConfigurationBuilder() + .SetBasePath(env.ContentRootPath) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true) + .AddEnvironmentVariables(); + + Configuration = builder.Build(); + } + + public void ConfigureServices(IServiceCollection services) + { + bool configResult = ConfigProcessor.LoadAndProcessConfig(Configuration["AppOptions:ConfigPath"]); + bool envConfigResult = ConfigProcessor.LoadAndProcessEnvVariables(Configuration.AsEnumerable()); + + // Same legacy ConnectionStrings bridge the other web hosts use (in-memory only). + var settings = System.Configuration.ConfigurationManager.ConnectionStrings; + var element = typeof(ConfigurationElement).GetField("_readOnly", BindingFlags.Instance | BindingFlags.NonPublic); + var collection = typeof(ConfigurationElementCollection).GetField("_readOnly", BindingFlags.Instance | BindingFlags.NonPublic); + + element.SetValue(settings, false); + collection.SetValue(settings, false); + + if (!configResult && !envConfigResult) + settings.Add(new ConnectionStringSettings("ResgridContext", Configuration["ConnectionStrings:ResgridContext"])); + else + settings.Add(new ConnectionStringSettings("ResgridContext", DataConfig.ConnectionString)); + + collection.SetValue(settings, true); + element.SetValue(settings, true); + + Framework.Logging.Initialize(ExternalErrorConfig.ExternalErrorServiceUrlForBroker); + + services.AddControllers(); + services.AddMemoryCache(); + services.AddHealthChecks(); + services.AddHostedService(); + } + + public void ConfigureContainer(ContainerBuilder builder) + { + // The broker resolves the same service graph the workers do (the migration coordinator + // runs here), so it mirrors the Bootstrapper module list... + builder.RegisterModule(new DataModule()); + builder.RegisterModule(new NoSqlDataModule()); + builder.RegisterModule(new ServicesModule()); + builder.RegisterModule(new ProviderModule()); + builder.RegisterModule(new EmailProviderModule()); + builder.RegisterModule(new BusModule()); + builder.RegisterModule(new RabbitBusModule()); + builder.RegisterModule(new AddressVerificationModule()); + builder.RegisterModule(new NumbersProviderModule()); + builder.RegisterModule(new CacheProviderModule()); + builder.RegisterModule(new MarketingModule()); + builder.RegisterModule(new PdfProviderModule()); + builder.RegisterModule(new MessagingProviderModule()); + builder.RegisterModule(new Resgrid.Providers.Voip.VoipProviderModule()); + builder.RegisterModule(new Resgrid.Providers.Weather.WeatherProviderModule()); + builder.RegisterModule(new Resgrid.Providers.Workflow.WorkflowProviderModule()); + + // ...plus the ONE registration no other host may load: the real KMS adapter. Last wins + // over ServicesModule's fail-closed NotConfigured placeholder. + builder.RegisterModule(new ProtectedDataProviderModule()); + + builder.RegisterType().AsSelf().SingleInstance(); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + AutofacContainer = app.ApplicationServices.GetAutofacRoot(); + + Locator = new AutofacServiceLocator(AutofacContainer); + ServiceLocator.SetLocatorProvider(() => Locator); + + ValidateCryptoConfiguration(); + + app.UseRouting(); + + // Workload gate for every broker API call; /health stays open for k8s probes. + app.UseMiddleware(); + + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapHealthChecks("/health"); + }); + } + + /// + /// Fail startup on invalid production crypto configuration (plan section 13): with the + /// OpenBao provider selected, resolving it forces the address/certificate checks its + /// constructor performs — the broker crashes now instead of failing every request later. + /// Missing grant-validation key material and a missing workload key are logged loudly but + /// non-fatal: every affected request already fails closed. + /// + private void ValidateCryptoConfiguration() + { + var keyWrappingProvider = AutofacContainer.Resolve(); + if (keyWrappingProvider is NotConfiguredKeyWrappingProvider) + throw new InvalidOperationException( + $"The Protected Data Broker has no usable key wrapping provider (configured type: '{DataProtectionConfig.KeyWrappingProviderType}'). Configure OpenBaoTransit (production) or LocalDev (synthetic testing only)."); + + var grantService = AutofacContainer.Resolve(); + if (!grantService.CanValidateGrants) + Framework.Logging.LogError( + "Protected Data Broker: no grant validation certificate is configured (DataProtectionConfig.GrantValidationCertificatePath). Every attended field-crypto request will be refused until one is provided."); + + if (string.IsNullOrWhiteSpace(DataProtectionConfig.BrokerApiKey)) + Framework.Logging.LogError( + "Protected Data Broker: DataProtectionConfig.BrokerApiKey is empty. Every request will be refused (503) until the workload key is provided."); + } + } +} diff --git a/Web/Resgrid.Web.Broker/appsettings.json b/Web/Resgrid.Web.Broker/appsettings.json new file mode 100644 index 000000000..621044617 --- /dev/null +++ b/Web/Resgrid.Web.Broker/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "AppOptions": { + "ConfigPath": "" + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 766253aa4..0a55352e4 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -59,6 +59,7 @@ public class CallsController : V4AuthenticatedApiControllerbaseSystemAuth private readonly ICallDispatchStatusService _callDispatchStatusService; private readonly IDispatchRecommendationService _dispatchRecommendationService; private readonly IFeatureToggleService _featureToggleService; + private readonly IDepartmentDataProtectionService _dataProtectionService; public CallsController( ICallsService callsService, @@ -83,9 +84,11 @@ public CallsController( IWeatherAlertService weatherAlertService, ICallDispatchStatusService callDispatchStatusService, IDispatchRecommendationService dispatchRecommendationService, - IFeatureToggleService featureToggleService + IFeatureToggleService featureToggleService, + IDepartmentDataProtectionService dataProtectionService ) { + _dataProtectionService = dataProtectionService; _callsService = callsService; _departmentsService = departmentsService; _userProfileService = userProfileService; @@ -112,6 +115,62 @@ IFeatureToggleService featureToggleService } #endregion Members and Constructors + /// + /// True when the caller authenticated as the BigBoard client application (numeric + /// UserSessionClientApplication claim; tokens predating the claim read as ordinary Api). + /// + private bool IsBigBoardSession => + string.Equals(User?.FindFirst(Resgrid.Model.Security.SessionClaimTypes.ClientApp)?.Value, + ((int)UserSessionClientApplication.BigBoard).ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal); + + /// + /// ADP plan section 7.3: BigBoard is an unattended display and is structurally stepped down. + /// For a protection-enforced department it receives only a safe shell — system-generated call + /// number, priority/status/state and safe timestamps survive; user-authored nature/name, + /// notes, identity, exact address/location and reference identifiers do not. Egress policy + /// can never relax this. + /// + private async Task ApplyBigBoardSafeShellAsync(IEnumerable calls) + { + if (calls == null || !IsBigBoardSession) + return false; + + if (!await _dataProtectionService.IsProtectionEnforcedAsync(DepartmentId)) + return false; + + foreach (var call in calls) + { + if (call == null) + continue; + + call.Name = "Protected incident — open Resgrid to view details."; + call.Nature = null; + call.Note = null; + call.Address = null; + call.DestinationName = null; + call.DestinationAddress = null; + call.DestinationTypeName = null; + call.DestinationPoiId = null; + call.DestinationPoiTypeId = null; + call.DestinationLatitude = null; + call.DestinationLongitude = null; + call.Geolocation = null; + call.What3Words = null; + call.ContactName = null; + call.ContactInfo = null; + call.ReferenceId = null; + call.ExternalId = null; + call.IncidentId = null; + call.AudioFileId = null; + call.Type = null; + call.Latitude = null; + call.Longitude = null; + } + + return true; + } + /// /// Returns all the active calls for the department /// @@ -147,6 +206,8 @@ public async Task> GetActiveCalls() destinationPoiLookup.TryGetValue(callWithData.DestinationPoiId.GetValueOrDefault(), out var destinationPoi); result.Data.Add(ConvertCall(callWithData, null, address, TimeZone, destinationPoi)); } + + await ApplyBigBoardSafeShellAsync(result.Data); result.PageSize = result.Data.Count(); result.Status = ResponseHelper.Success; } @@ -217,6 +278,16 @@ public async Task> GetCall(string callId, [FromQuery result.Data = ConvertCall(c, protocols, address, TimeZone, destinationPoi); + // BigBoard shells also suppress UDF submissions — user-authored free text defaults to + // sensitive in a protected department (plan section 5.2). + if (await ApplyBigBoardSafeShellAsync(new[] { result.Data })) + { + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return Ok(result); + } + // Populate UDF values var udfValues = await _userDefinedFieldsService.GetFieldValuesForEntityAsync(effectiveDepartmentId, (int)UdfEntityType.Call, c.CallId.ToString()); if (udfValues != null && udfValues.Any()) @@ -267,7 +338,12 @@ public async Task> GetCallExtraData(int callId call = await _callsService.PopulateCallData(call, true, true, true, true, true, true, true, true, true); - result.Data.CallFormData = call.CallFormData; + // BigBoard step-down: dispatched unit/personnel state below is allowlisted resource + // state, but submitted call form data is protected content (plan section 7.3). + if (IsBigBoardSession && await _dataProtectionService.IsProtectionEnforcedAsync(DepartmentId)) + result.Data.CallFormData = null; + else + result.Data.CallFormData = call.CallFormData; var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); var units = await _unitsService.GetUnitsForDepartmentAsync(call.DepartmentId); @@ -1657,6 +1733,8 @@ public async Task> GetAllPendingScheduledCall destinationPoiLookup.TryGetValue(c.DestinationPoiId.GetValueOrDefault(), out var destinationPoi); result.Data.Add(ConvertCall(c, null, address, TimeZone, destinationPoi)); } + + await ApplyBigBoardSafeShellAsync(result.Data); result.PageSize = result.Data.Count(); result.Status = ResponseHelper.Success; } @@ -1934,6 +2012,8 @@ public async Task> GetCalls(DateTime startDate, destinationPoiLookup.TryGetValue(callWithData.DestinationPoiId.GetValueOrDefault(), out var destinationPoi); result.Data.Add(ConvertCall(callWithData, null, address, TimeZone, destinationPoi)); } + + await ApplyBigBoardSafeShellAsync(result.Data); result.PageSize = result.Data.Count(); result.Status = ResponseHelper.Success; } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs index d289e77d2..25ea66514 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ConnectController.cs @@ -182,6 +182,50 @@ public async Task Token() return Forbid(properties, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } + // ── Resgrid 2FA challenge ──────────────────────────────────────────────── + // A password alone is insufficient for an account with Two-Factor enabled: the + // current authenticator code must accompany the request as totp_code. This closes + // the gap where only the SSO exchange enforced 2FA. The code is checked AFTER the + // password so this endpoint never becomes a TOTP oracle for unauthenticated callers. + if (await _userManager.GetTwoFactorEnabledAsync(user)) + { + var totpCode = (string)request.GetParameter("totp_code"); + if (string.IsNullOrWhiteSpace(totpCode)) + { + audit.Successful = false; + audit.Data += " (mfa_required)"; + await _systemAuditsService.SaveSystemAuditAsync(audit); + + return Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = "mfa_required", + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "Two-factor authentication is enabled for this account. Include your current totp_code with this request." + }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + + var totpValid = await _userManager.VerifyTwoFactorTokenAsync(user, + _userManager.Options.Tokens.AuthenticatorTokenProvider, totpCode.Trim()); + if (!totpValid) + { + // A caller who already holds the password must not get unlimited code + // guesses: count the failure against the same Identity lockout the + // password check uses. + await _userManager.AccessFailedAsync(user); + + audit.Successful = false; + audit.Data += " (invalid_totp)"; + await _systemAuditsService.SaveSystemAuditAsync(audit); + + return Forbid(new AuthenticationProperties(new Dictionary + { + [OpenIddictServerAspNetCoreConstants.Properties.Error] = "invalid_totp", + [OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = + "The two-factor authentication code is invalid or has expired." + }), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + } + } + // Create a new ClaimsPrincipal containing the claims that // will be used to create an id_token, a token or a code. var principal = await _signInManager.CreateUserPrincipalAsync(user); @@ -1178,10 +1222,14 @@ private static void AddSessionClaims(ClaimsPrincipal principal, UserSession sess identity.RemoveClaim(existing); foreach (var existing in identity.FindAll(SessionClaimTypes.AuthenticationGeneration).ToList()) identity.RemoveClaim(existing); + foreach (var existing in identity.FindAll(SessionClaimTypes.ClientApp).ToList()) + identity.RemoveClaim(existing); identity.AddClaim(new Claim(SessionClaimTypes.SessionId, session.UserSessionId)); identity.AddClaim(new Claim(SessionClaimTypes.AuthenticationGeneration, session.AuthenticationGeneration.ToString(System.Globalization.CultureInfo.InvariantCulture))); + identity.AddClaim(new Claim(SessionClaimTypes.ClientApp, + session.ClientApplication.ToString(System.Globalization.CultureInfo.InvariantCulture))); } private IEnumerable GetDestinations(Claim claim, ClaimsPrincipal principal) { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs new file mode 100644 index 000000000..813c374a8 --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/DataProtectionController.cs @@ -0,0 +1,350 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Web.Services.Filters; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.DataProtection; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Advanced Data Protection capability and enrollment API (ADP plan sections 7.1, 12, 18). + /// The capability report is value-free and advisory; every command is re-validated server-side — + /// managing member only (ordinary admins, including ManageDepartmentDataProtection holders, are + /// denied), active paid ADP addon, a fresh authoritative global-gate evaluation performed by the + /// service immediately before commit, and (where grant key material is deployed) a + /// currently-valid Protected Data Grant in X-Resgrid-Protected-Grant proving recent MFA + /// (RequireRecentMfaAsync). + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class DataProtectionController : V4AuthenticatedApiControllerbase + { + // TOTP step-up brute-force limiter: attempts per user inside the window before 429. + private const int StepUpMaxAttempts = 5; + private static readonly TimeSpan StepUpAttemptWindow = TimeSpan.FromMinutes(5); + + /// Header carrying the caller's Protected Data Grant on MFA-gated commands. + public const string GrantHeader = "X-Resgrid-Protected-Grant"; + + private readonly IDepartmentDataProtectionService _dataProtectionService; + private readonly IDepartmentLockService _departmentLockService; + private readonly IProtectedFieldCatalog _protectedFieldCatalog; + private readonly IDepartmentsService _departmentsService; + private readonly IFeatureToggleService _featureToggleService; + private readonly UserManager _userManager; + private readonly ICacheProvider _cacheProvider; + private readonly IProtectedDataGrantService _grantService; + + public DataProtectionController(IDepartmentDataProtectionService dataProtectionService, + IDepartmentLockService departmentLockService, IProtectedFieldCatalog protectedFieldCatalog, + IDepartmentsService departmentsService, IFeatureToggleService featureToggleService, + UserManager userManager, ICacheProvider cacheProvider, + IProtectedDataGrantService grantService) + { + _dataProtectionService = dataProtectionService; + _departmentLockService = departmentLockService; + _protectedFieldCatalog = protectedFieldCatalog; + _departmentsService = departmentsService; + _featureToggleService = featureToggleService; + _userManager = userManager; + _cacheProvider = cacheProvider; + _grantService = grantService; + } + + /// + /// Value-free ADP capability report for the caller's department: durable state, catalog and + /// policy versions, step-up window, egress summary, and lock state. Never returns protected + /// values, ciphertext, or key material. + /// + [HttpGet("Capabilities")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> Capabilities() + { + var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(DepartmentId); + var state = policy == null ? DepartmentDataProtectionState.Disabled : (DepartmentDataProtectionState)policy.State; + var egress = await _dataProtectionService.GetEgressPolicyByDepartmentIdAsync(DepartmentId); + var activeLock = await _departmentLockService.GetActiveLockAsync(DepartmentId); + var isLocked = await _departmentLockService.IsDepartmentLockedAsync(DepartmentId); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + var isManagingMember = department != null && + string.Equals(department.ManagingUserId, UserId, StringComparison.OrdinalIgnoreCase); + + // Advisory gate read (ordinary cached path is fine here; commands re-evaluate fresh). + var gateOpen = false; + try + { + var gate = await _featureToggleService.GetFlagByKeyAsync(FeatureFlagKeys.DepartmentProtectedDataEnrollment); + gateOpen = gate != null && !gate.IsArchived && gate.IsEnabledGlobally; + } + catch + { + // Advisory only — a flag-store fault reads as "gate closed". + } + + var result = new DataProtectionCapabilitiesResult + { + Data = new DataProtectionCapabilitiesData + { + State = (int)state, + StateName = state.ToString(), + IsProtectionEnabled = await _dataProtectionService.IsProtectionEnforcedAsync(DepartmentId), + IsEnrollmentAvailable = gateOpen && state == DepartmentDataProtectionState.Disabled, + CanEnable = isManagingMember && state == DepartmentDataProtectionState.Disabled, + CanDisable = isManagingMember && + (state == DepartmentDataProtectionState.Enabled || + state == DepartmentDataProtectionState.EnrollmentQueued || + state == DepartmentDataProtectionState.OffboardingScheduled), + ReenableRequiresFeatureFlag = true, + CatalogVersion = policy?.CatalogVersion ?? 0, + CurrentCatalogVersion = _protectedFieldCatalog.Version, + PolicyEpoch = policy?.PolicyEpoch ?? 0, + StepUpWindowMinutes = policy?.StepUpWindowMinutes ?? Config.DataProtectionConfig.StepUpWindowDefaultMinutes, + OffboardingEffectiveOn = policy?.OffboardingEffectiveOn?.ToString("O"), + PushEgressMode = egress.PushMode, + EmailEgressMode = egress.EmailMode, + SmsEgressMode = egress.SmsMode, + VoiceEgressMode = egress.VoiceMode, + IsDepartmentLocked = isLocked, + LockReason = isLocked ? activeLock?.Reason : null, + LockProjectedEndUtc = isLocked ? activeLock?.ProjectedEndUtc?.ToString("O") : null + } + }; + + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Verifies the caller's authenticator (TOTP) code for the ADP step-up (plan section 3). + /// Success returns the absolute expiry of the step-up window — clients hold it in memory + /// only, conceal protected values at expiry, and prompt again on the next reveal/edit. + /// Refreshing an access token never refreshes this window. Allowed during a department lock: + /// step-up is a read-side control and reads continue while locked. Attempts are rate limited + /// per user; the code is never logged. + /// + [HttpPost("VerifyStepUp")] + [AllowDuringDepartmentLock] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> VerifyStepUp([FromBody] VerifyStepUpInput input) + { + if (string.IsNullOrWhiteSpace(input?.Code)) + return Problem(type: "invalid_totp", title: "A verification code is required.", + statusCode: StatusCodes.Status400BadRequest); + + // Brute-force limiter (fail open on cache faults: lockout also guards below via TOTP + // time-step; a cache outage must not disable step-up entirely). + var attempts = await _cacheProvider.IncrementAsync($"AdpStepUpAttempts_{UserId}", StepUpAttemptWindow); + if (attempts > StepUpMaxAttempts) + return Problem(type: "too_many_attempts", + title: "Too many verification attempts. Wait a few minutes and try again.", + statusCode: StatusCodes.Status429TooManyRequests); + + var user = await _userManager.FindByIdAsync(UserId); + if (user == null) + return Problem(type: "protected_access_denied", title: "User not found.", + statusCode: StatusCodes.Status401Unauthorized); + + if (!await _userManager.GetTwoFactorEnabledAsync(user)) + return Problem(type: "mfa_not_enrolled", + title: "Two-factor authentication is not enrolled for this account. Enroll an authenticator app in account security settings first.", + statusCode: StatusCodes.Status409Conflict); + + var valid = await _userManager.VerifyTwoFactorTokenAsync(user, + _userManager.Options.Tokens.AuthenticatorTokenProvider, input.Code.Trim()); + if (!valid) + return Problem(type: "invalid_totp", + title: "The verification code is invalid or has expired.", + statusCode: StatusCodes.Status401Unauthorized); + + var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(DepartmentId); + var windowMinutes = policy?.StepUpWindowMinutes > 0 + ? policy.StepUpWindowMinutes + : Config.DataProtectionConfig.StepUpWindowDefaultMinutes; + + // Same clamp IssueGrant applies: the advertised window must never exceed the grant's + // actual lifetime, or clients would keep protected values visible past expiry. + windowMinutes = Math.Min(Math.Max(1, windowMinutes), Math.Max(1, Config.DataProtectionConfig.StepUpMaximumMinutes)); + + var result = new StepUpResult + { + GrantId = null, + StepUpExpiresOnUtc = DateTime.UtcNow.AddMinutes(windowMinutes).ToString("O"), + StepUpWindowMinutes = windowMinutes + }; + + // When signing key material is configured (identity tier), the verification mints a real + // Protected Data Grant bound to user, department, session, client app, policy epoch and + // this moment's MFA. Without it, the response keeps the pre-broker shape (null grant). + if (_grantService.CanIssueGrants) + { + var issued = _grantService.IssueGrant(new ProtectedDataGrantIssueRequest + { + UserId = UserId, + DepartmentId = DepartmentId, + SessionId = User.FindFirst(Model.Security.SessionClaimTypes.SessionId)?.Value, + ClientApp = int.TryParse(User.FindFirst(Model.Security.SessionClaimTypes.ClientApp)?.Value, out var clientApp) + ? clientApp + : (int)UserSessionClientApplication.Api, + PolicyEpoch = policy?.PolicyEpoch ?? 0, + WindowMinutes = windowMinutes, + Scopes = new[] { ProtectedDataGrantScopes.Read, ProtectedDataGrantScopes.Write }, + MfaAtUtc = DateTime.UtcNow + }); + + result.GrantId = issued.GrantId; + result.GrantToken = issued.Token; + result.StepUpExpiresOnUtc = issued.ExpiresOnUtc.ToString("O"); + } + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + + /// + /// Queues enrollment (Disabled -> EnrollmentQueued). Managing member only; requires an active + /// paid ADP addon and an open global admission gate, both re-verified server-side. + /// + [HttpPost("QueueEnrollment")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> QueueEnrollment([FromBody] QueueEnrollmentInput input) + { + var mfaProblem = await RequireRecentMfaAsync(); + if (mfaProblem != null) + return mfaProblem; + + var outcome = await _dataProtectionService.QueueEnrollmentAsync(DepartmentId, UserId, + input?.AcknowledgementsJson, input?.WindowStartLocal, input?.WindowEndLocal, input?.WindowTimeZone); + return await MapCommandOutcomeAsync(outcome); + } + + /// Dequeues a not-yet-started enrollment (EnrollmentQueued -> Disabled). Managing member only. + [HttpPost("CancelQueuedEnrollment")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> CancelQueuedEnrollment() + { + var mfaProblem = await RequireRecentMfaAsync(); + if (mfaProblem != null) + return mfaProblem; + + var outcome = await _dataProtectionService.CancelQueuedEnrollmentAsync(DepartmentId, UserId); + return await MapCommandOutcomeAsync(outcome); + } + + /// + /// Revokes a scheduled offboarding (OffboardingScheduled -> Enabled) before the first + /// offboarding window opens. Managing member only. Allowed during a department lock: the + /// revoke window closes when offboarding execution starts, and this command must not be + /// blocked by an unrelated migration window. + /// + [HttpPost("RevokeOffboarding")] + [AllowDuringDepartmentLock] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize] + public async Task> RevokeOffboarding() + { + var mfaProblem = await RequireRecentMfaAsync(); + if (mfaProblem != null) + return mfaProblem; + + var outcome = await _dataProtectionService.RevokeOffboardingAsync(DepartmentId, UserId); + return await MapCommandOutcomeAsync(outcome); + } + + /// + /// MFA-recency gate for enrollment/offboarding commands (plan sections 3.5 and 18): the + /// caller must present a currently-valid Protected Data Grant — minted by VerifyStepUp after + /// fresh TOTP, absolute lifetime = the department step-up window — in the + /// X-Resgrid-Protected-Grant header, bound to THIS user and department at the CURRENT policy + /// epoch. On deployments without grant key material (CanValidateGrants false) the gate is + /// inactive and the pre-Phase-2 gates (managing member, addon, global flag) stand alone. + /// Returns null when the command may proceed. + /// + private async Task RequireRecentMfaAsync() + { + if (!_grantService.CanValidateGrants) + return null; + + var token = Request.Headers[GrantHeader].ToString(); + var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(DepartmentId); + var outcome = _grantService.ValidateGrant(token, DepartmentId, policy?.PolicyEpoch ?? 0, + requiredScope: null, out var grant); + + if (outcome != ProtectedDataGrantValidationOutcome.Valid || + !string.Equals(grant.UserId, UserId, StringComparison.OrdinalIgnoreCase)) + return Problem(type: "step_up_required", + title: "Recent multi-factor verification is required for this command. Verify your authenticator code and retry with the issued grant.", + statusCode: StatusCodes.Status403Forbidden); + + return null; + } + + private async Task> MapCommandOutcomeAsync(DepartmentDataProtectionEnrollmentResult outcome) + { + switch (outcome) + { + case DepartmentDataProtectionEnrollmentResult.Queued: + var state = await _dataProtectionService.GetStateAsync(DepartmentId, bypassCache: true); + var result = new EnrollmentCommandResult + { + Outcome = outcome.ToString(), + State = (int)state + }; + result.PageSize = 1; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + + case DepartmentDataProtectionEnrollmentResult.NotManagingMember: + return Problem(type: "protected_access_denied", + title: "Only the department's managing member may run this command.", + statusCode: StatusCodes.Status403Forbidden); + + case DepartmentDataProtectionEnrollmentResult.AddonRequired: + return Problem(type: "addon_required", + title: "An active Advanced Data Protection addon is required.", + statusCode: StatusCodes.Status409Conflict); + + case DepartmentDataProtectionEnrollmentResult.PlanRequired: + return Problem(type: "plan_required", + title: "Advanced Data Protection requires a paid plan.", + statusCode: StatusCodes.Status409Conflict); + + case DepartmentDataProtectionEnrollmentResult.FeatureNotAvailable: + return Problem(type: "feature_not_available", + title: "Advanced Data Protection enrollment is temporarily unavailable.", + statusCode: StatusCodes.Status409Conflict); + + case DepartmentDataProtectionEnrollmentResult.InvalidState: + return Problem(type: "invalid_state", + title: "The department's protection state does not permit this command.", + statusCode: StatusCodes.Status409Conflict); + + case DepartmentDataProtectionEnrollmentResult.InvalidWindow: + return Problem(type: "invalid_window", + title: "A valid migration window time zone is required (select one in the wizard, or set the department time zone).", + statusCode: StatusCodes.Status400BadRequest); + + default: + return Problem(type: "command_failed", + title: "The command could not be completed; it may be retried.", + statusCode: StatusCodes.Status500InternalServerError); + } + } + } +} diff --git a/Web/Resgrid.Web.Services/Filters/AllowDuringDepartmentLockAttribute.cs b/Web/Resgrid.Web.Services/Filters/AllowDuringDepartmentLockAttribute.cs new file mode 100644 index 000000000..a41626425 --- /dev/null +++ b/Web/Resgrid.Web.Services/Filters/AllowDuringDepartmentLockAttribute.cs @@ -0,0 +1,16 @@ +using System; + +namespace Resgrid.Web.Services.Filters +{ + /// + /// Exempts an endpoint (or a whole controller) from + /// mutation blocking. Use ONLY for operations that must stay available while a department + /// operation lock is active: authentication/session flows, capability/status reads that happen + /// to use POST, and the ADP lock-abort path itself (dispatch beats migration — the abort can + /// never be blocked by the very lock it releases). + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public sealed class AllowDuringDepartmentLockAttribute : Attribute + { + } +} diff --git a/Web/Resgrid.Web.Services/Filters/DepartmentLockActionFilter.cs b/Web/Resgrid.Web.Services/Filters/DepartmentLockActionFilter.cs new file mode 100644 index 000000000..b0d3fc512 --- /dev/null +++ b/Web/Resgrid.Web.Services/Filters/DepartmentLockActionFilter.cs @@ -0,0 +1,97 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Services.Filters +{ + /// + /// Global department-operation-lock mutation gate (ADP plan section 20.2). While a department's + /// lock is active, every authenticated mutating request (POST/PUT/PATCH/DELETE) for that + /// department is refused with 423 Locked and problem type "department_locked"; reads always + /// continue. Department identity comes from the signed authentication state + /// (ClaimTypes.PrimaryGroupSid), never from the request body, so a caller cannot dodge the gate + /// by naming another department. Endpoints that must work during a lock (auth/session flows, + /// status reads over POST, the lock-abort path) opt out with + /// . + /// + /// Failure posture matches IDepartmentLockService: a lock-store outage fails OPEN — the lock + /// protects a migration, but dispatch availability beats migration progress, and the migration + /// worker separately refuses to proceed when it cannot verify its own lock. + /// + public sealed class DepartmentLockActionFilter : IAsyncActionFilter + { + public const string ProblemType = "department_locked"; + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var method = context.HttpContext.Request.Method; + var isMutation = HttpMethods.IsPost(method) || HttpMethods.IsPut(method) || + HttpMethods.IsPatch(method) || HttpMethods.IsDelete(method); + + // Department from the signed authentication state only. Anonymous endpoints (webhooks, + // auth) have no department claim and pass through — their department-scoped work happens + // in queue consumers, which enforce the lock at the consumer (section 20.2). + var departmentClaim = context.HttpContext.User?.FindFirst(ClaimTypes.PrimaryGroupSid)?.Value; + + if (!isMutation || HasAllowAttribute(context) || + !int.TryParse(departmentClaim, out var departmentId) || departmentId <= 0) + { + await next(); + return; + } + + var lockService = context.HttpContext.RequestServices?.GetService(typeof(IDepartmentLockService)) as IDepartmentLockService; + + var isLocked = false; + DepartmentOperationLock activeLock = null; + if (lockService != null) + { + try + { + isLocked = await lockService.IsDepartmentLockedAsync(departmentId); + if (isLocked) + activeLock = await lockService.GetActiveLockAsync(departmentId); + } + catch (Exception ex) + { + Logging.LogException(ex, $"DepartmentLockActionFilter failed for department {departmentId}; failing open"); + isLocked = false; + } + } + + if (!isLocked) + { + await next(); + return; + } + + context.Result = new ObjectResult(new ProblemDetails + { + Type = ProblemType, + Title = "Department is temporarily locked", + Detail = activeLock?.Reason ?? "A maintenance operation is in progress; data entry is paused. Reads remain available.", + Status = StatusCodes.Status423Locked, + Extensions = + { + ["projectedEndUtc"] = activeLock?.ProjectedEndUtc, + ["lockType"] = activeLock?.LockType + } + }) + { + StatusCode = StatusCodes.Status423Locked + }; + } + + private static bool HasAllowAttribute(ActionExecutingContext context) + { + return context.ActionDescriptor.EndpointMetadata?.OfType().Any() == true; + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionInputs.cs b/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionInputs.cs new file mode 100644 index 000000000..a90abcfa4 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionInputs.cs @@ -0,0 +1,30 @@ +namespace Resgrid.Web.Services.Models.v4.DataProtection +{ + /// + /// Step-up verification payload: the user's current authenticator (TOTP) code. Never logged. + /// + public class VerifyStepUpInput + { + public string Code { get; set; } + } + + /// + /// Enrollment Wizard final-confirmation payload (ADP plan section 18.1 step 8). Everything here is + /// re-validated server-side: caller must be the managing member, addon and global gate are + /// re-checked, and the durable state must be Disabled. + /// + public class QueueEnrollmentInput + { + /// Versioned acknowledgement record from the wizard (section 12 disclosure items). + public string AcknowledgementsJson { get; set; } + + /// Department-local overnight window start, "HH:mm" (default 22:00). + public string WindowStartLocal { get; set; } + + /// Department-local overnight window end, "HH:mm" (default 06:00). + public string WindowEndLocal { get; set; } + + /// Time zone id the window is evaluated in. + public string WindowTimeZone { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionResults.cs b/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionResults.cs new file mode 100644 index 000000000..a2d283e43 --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/DataProtection/DataProtectionResults.cs @@ -0,0 +1,99 @@ +namespace Resgrid.Web.Services.Models.v4.DataProtection +{ + /// + /// ADP capability report for the calling department (ADP plan sections 7.1 and 12). Contains NO + /// protected values. Flag/addon eligibility here is ADVISORY — every enrollment command re-checks + /// server-side; the durable state and the admission gate are never collapsed into one boolean. + /// + public class DataProtectionCapabilitiesData + { + /// DepartmentDataProtectionState numeric value. + public int State { get; set; } + + /// DepartmentDataProtectionState name for display/debugging. + public string StateName { get; set; } + + /// True when protection is enforced for reads (Enabled/Rotating/OffboardingScheduled). + public bool IsProtectionEnabled { get; set; } + + /// Advisory: global admission gate currently open AND the department could start enrollment. + public bool IsEnrollmentAvailable { get; set; } + + /// Advisory: caller is the managing member and state permits an enroll command. + public bool CanEnable { get; set; } + + /// Advisory: caller is the managing member and state permits cancel/offboarding control. + public bool CanDisable { get; set; } + + /// Re-enabling after a completed opt-out always requires a fresh purchase and open gate. + public bool ReenableRequiresFeatureFlag { get; set; } + + /// Catalog version this department's migration has verified (0 = none). + public int CatalogVersion { get; set; } + + /// Current platform catalog version. + public int CurrentCatalogVersion { get; set; } + + /// Department policy epoch; clients discard grants/state from older epochs. + public long PolicyEpoch { get; set; } + + /// Effective Protected Data Grant lifetime in minutes. + public int StepUpWindowMinutes { get; set; } + + /// Scheduled offboarding instant (ISO 8601), when state is OffboardingScheduled. + public string OffboardingEffectiveOn { get; set; } + + /// Per-channel egress modes (ProtectedDataEgressMode numeric values). + public int PushEgressMode { get; set; } + public int EmailEgressMode { get; set; } + public int SmsEgressMode { get; set; } + public int VoiceEgressMode { get; set; } + + /// True while a department operation lock is active (mutations refused with 423). + public bool IsDepartmentLocked { get; set; } + + /// Value-free lock banner reason, when locked. + public string LockReason { get; set; } + + /// Projected lock end (ISO 8601), when locked and known. + public string LockProjectedEndUtc { get; set; } + } + + public class DataProtectionCapabilitiesResult : StandardApiResponseV4Base + { + public DataProtectionCapabilitiesData Data { get; set; } = new DataProtectionCapabilitiesData(); + } + + public class EnrollmentCommandResult : StandardApiResponseV4Base + { + /// DepartmentDataProtectionEnrollmentResult name. + public string Outcome { get; set; } + + /// Resulting DepartmentDataProtectionState numeric value. + public int State { get; set; } + } + + /// + /// Result of a successful step-up verification. The window is ABSOLUTE (never sliding): clients + /// conceal protected values at StepUpExpiresOnUtc and prompt again on the next reveal/edit. + /// When grant signing is configured on this deployment, GrantId/GrantToken carry a signed + /// Protected Data Grant the client presents alongside its access token on protected operations; + /// clients hold the token in MEMORY ONLY (never persisted) and discard it at expiry. On + /// deployments without signing key material both stay null and the verification itself remains + /// the capability (pre-broker behavior). + /// + public class StepUpResult : StandardApiResponseV4Base + { + /// Unique grant id (jti) for display/audit correlation; null when grants are not configured. + public string GrantId { get; set; } + + /// Signed Protected Data Grant token; null when grants are not configured. MEMORY ONLY. + public string GrantToken { get; set; } + + /// Absolute UTC expiry of this step-up window (ISO 8601). + public string StepUpExpiresOnUtc { get; set; } + + /// The department's effective step-up window in minutes. + public int StepUpWindowMinutes { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj b/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj index 41c57a193..c2a304767 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.csproj @@ -128,6 +128,7 @@ + diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 3acf4eb57..633ce3f22 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -302,6 +302,21 @@ Calls, also referred to as Dispatches. + + + True when the caller authenticated as the BigBoard client application (numeric + UserSessionClientApplication claim; tokens predating the claim read as ordinary Api). + + + + + ADP plan section 7.3: BigBoard is an unattended display and is structurally stepped down. + For a protection-enforced department it receives only a safe shell — system-generated call + number, priority/status/state and safe timestamps survive; user-authored nature/name, + notes, identity, exact address/location and reference identifiers do not. Egress policy + can never relax this. + + Returns all the active calls for the department @@ -1356,6 +1371,65 @@ + + + Advanced Data Protection capability and enrollment API (ADP plan sections 7.1, 12, 18). + The capability report is value-free and advisory; every command is re-validated server-side — + managing member only (ordinary admins, including ManageDepartmentDataProtection holders, are + denied), active paid ADP addon, a fresh authoritative global-gate evaluation performed by the + service immediately before commit, and (where grant key material is deployed) a + currently-valid Protected Data Grant in X-Resgrid-Protected-Grant proving recent MFA + (RequireRecentMfaAsync). + + + + Header carrying the caller's Protected Data Grant on MFA-gated commands. + + + + Value-free ADP capability report for the caller's department: durable state, catalog and + policy versions, step-up window, egress summary, and lock state. Never returns protected + values, ciphertext, or key material. + + + + + Verifies the caller's authenticator (TOTP) code for the ADP step-up (plan section 3). + Success returns the absolute expiry of the step-up window — clients hold it in memory + only, conceal protected values at expiry, and prompt again on the next reveal/edit. + Refreshing an access token never refreshes this window. Allowed during a department lock: + step-up is a read-side control and reads continue while locked. Attempts are rate limited + per user; the code is never logged. + + + + + Queues enrollment (Disabled -> EnrollmentQueued). Managing member only; requires an active + paid ADP addon and an open global admission gate, both re-verified server-side. + + + + Dequeues a not-yet-started enrollment (EnrollmentQueued -> Disabled). Managing member only. + + + + Revokes a scheduled offboarding (OffboardingScheduled -> Enabled) before the first + offboarding window opens. Managing member only. Allowed during a department lock: the + revoke window closes when offboarding execution starts, and this command must not be + blocked by an unrelated migration window. + + + + + MFA-recency gate for enrollment/offboarding commands (plan sections 3.5 and 18): the + caller must present a currently-valid Protected Data Grant — minted by VerifyStepUp after + fresh TOTP, absolute lifetime = the department step-up window — in the + X-Resgrid-Protected-Grant header, bound to THIS user and department at the CURRENT policy + epoch. On deployments without grant key material (CanValidateGrants false) the gate is + inactive and the pre-Phase-2 gates (managing member, addon, global flag) stand alone. + Returns null when the command may proceed. + + Department-level lookup operations used by external integrations such as the SMTP relay @@ -5359,6 +5433,31 @@ The Timestamp of the status + + + Exempts an endpoint (or a whole controller) from + mutation blocking. Use ONLY for operations that must stay available while a department + operation lock is active: authentication/session flows, capability/status reads that happen + to use POST, and the ADP lock-abort path itself (dispatch beats migration — the abort can + never be blocked by the very lock it releases). + + + + + Global department-operation-lock mutation gate (ADP plan section 20.2). While a department's + lock is active, every authenticated mutating request (POST/PUT/PATCH/DELETE) for that + department is refused with 423 Locked and problem type "department_locked"; reads always + continue. Department identity comes from the signed authentication state + (ClaimTypes.PrimaryGroupSid), never from the request body, so a caller cannot dodge the gate + by naming another department. Endpoints that must work during a lock (auth/session flows, + status reads over POST, the lock-abort path) opt out with + . + + Failure posture matches IDepartmentLockService: a lock-store outage fails OPEN — the lock + protects a migration, but dispatch availability beats migration progress, and the migration + worker separately refuses to proceed when it cannot verify its own lock. + + Per-endpoint, incident-scoped capability gate (§3.11). Layered ON TOP of the broad @@ -9398,6 +9497,114 @@ Is this custom status deleted (only should be used for display) + + + Step-up verification payload: the user's current authenticator (TOTP) code. Never logged. + + + + + Enrollment Wizard final-confirmation payload (ADP plan section 18.1 step 8). Everything here is + re-validated server-side: caller must be the managing member, addon and global gate are + re-checked, and the durable state must be Disabled. + + + + Versioned acknowledgement record from the wizard (section 12 disclosure items). + + + Department-local overnight window start, "HH:mm" (default 22:00). + + + Department-local overnight window end, "HH:mm" (default 06:00). + + + Time zone id the window is evaluated in. + + + + ADP capability report for the calling department (ADP plan sections 7.1 and 12). Contains NO + protected values. Flag/addon eligibility here is ADVISORY — every enrollment command re-checks + server-side; the durable state and the admission gate are never collapsed into one boolean. + + + + DepartmentDataProtectionState numeric value. + + + DepartmentDataProtectionState name for display/debugging. + + + True when protection is enforced for reads (Enabled/Rotating/OffboardingScheduled). + + + Advisory: global admission gate currently open AND the department could start enrollment. + + + Advisory: caller is the managing member and state permits an enroll command. + + + Advisory: caller is the managing member and state permits cancel/offboarding control. + + + Re-enabling after a completed opt-out always requires a fresh purchase and open gate. + + + Catalog version this department's migration has verified (0 = none). + + + Current platform catalog version. + + + Department policy epoch; clients discard grants/state from older epochs. + + + Effective Protected Data Grant lifetime in minutes. + + + Scheduled offboarding instant (ISO 8601), when state is OffboardingScheduled. + + + Per-channel egress modes (ProtectedDataEgressMode numeric values). + + + True while a department operation lock is active (mutations refused with 423). + + + Value-free lock banner reason, when locked. + + + Projected lock end (ISO 8601), when locked and known. + + + DepartmentDataProtectionEnrollmentResult name. + + + Resulting DepartmentDataProtectionState numeric value. + + + + Result of a successful step-up verification. The window is ABSOLUTE (never sliding): clients + conceal protected values at StepUpExpiresOnUtc and prompt again on the next reveal/edit. + When grant signing is configured on this deployment, GrantId/GrantToken carry a signed + Protected Data Grant the client presents alongside its access token on protected operations; + clients hold the token in MEMORY ONLY (never persisted) and discard it at expiry. On + deployments without signing key material both stay null and the verification itself remains + the capability (pre-broker behavior). + + + + Unique grant id (jti) for display/audit correlation; null when grants are not configured. + + + Signed Protected Data Grant token; null when grants are not configured. MEMORY ONLY. + + + Absolute UTC expiry of this step-up window (ISO 8601). + + + The department's effective step-up window in minutes. + Result of a department lookup by dispatch email code. diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 9cd61300a..12b3c93c7 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -166,7 +166,12 @@ public void ConfigureServices(IServiceCollection services) services.AddCors(); - services.AddControllers().AddNewtonsoftJson(options => + services.AddControllers(options => + { + // ADP department operation lock: refuses department-scoped mutations with 423 Locked + // while a migration window holds the department's lock; reads pass through untouched. + options.Filters.Add(); + }).AddNewtonsoftJson(options => { options.SerializerSettings.ContractResolver = new DefaultContractResolver(); }); @@ -667,6 +672,9 @@ public void ConfigureContainer(ContainerBuilder builder) builder.RegisterModule(new Resgrid.Chatbot.ChatbotModule()); builder.RegisterModule(new Resgrid.Chatbot.NLU.NLUModule()); builder.RegisterModule(new Resgrid.Providers.Chatbot.ChatbotProviderModule()); + // ADP broker CLIENT only (no key material, no KMS route) — the app tier asks the broker + // to act on a caller's grant. The real KMS adapter module is broker-host-only. + builder.RegisterModule(new Resgrid.Providers.ProtectedData.ProtectedDataBrokerClientModule()); builder.RegisterType().As>().InstancePerLifetimeScope(); builder.RegisterType().As>().InstancePerLifetimeScope(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs new file mode 100644 index 000000000..fe6fad6b0 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Newtonsoft.Json; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Web.Areas.User.Models.DataProtection; +using Resgrid.Web.Attributes; +using Resgrid.Web.Filters; +using Resgrid.Web.Helpers; +using IdentityUser = Resgrid.Model.Identity.IdentityUser; + +namespace Resgrid.Web.Areas.User.Controllers +{ + /// + /// Advanced Data Protection status page and Enrollment Wizard (plan sections 3.5, 12, 18). + /// The page renders per the section 3.5 state table (wizard only for a Disabled department with + /// an open gate and active addon; queue/progress/offboarding controls otherwise). Every command + /// is server-enforced regardless of what the page showed: managing member only, active paid + /// addon, fresh global-gate evaluation (inside QueueEnrollmentAsync), per-operation MFA step-up + /// (RequiresRecentTwoFactor), and antiforgery. Acknowledgements are versioned and validated + /// server-side against the full section 12 item list — a client that omits one cannot enroll. + /// + [Area("User")] + [Authorize] + public class DataProtectionController : SecureBaseController + { + /// Version stamp recorded with every acknowledgement set; bump when section 12 text changes. + public const string AcknowledgementVersion = "ADP-ACK-1"; + + /// + /// The section 12 disclosure items. Item KEYS are stable identifiers recorded in the policy's + /// acknowledgement JSON; the wizard renders matching text and the queue action refuses any + /// submission that does not acknowledge every key. + /// + public static readonly IReadOnlyList AckItems = new[] + { + "catalog_scope", + "plaintext_metadata", + "authorized_server_access", + "step_up_window", + "bigboard_reduction", + "workflow_redaction", + "default_egress", + "search_report_limitations", + "migration_disable", + "key_loss_support", + "not_hipaa_compliance" + }; + + private readonly IDepartmentDataProtectionService _dataProtectionService; + private readonly IDepartmentLockService _departmentLockService; + private readonly IAdpSizingService _sizingService; + private readonly IProtectedDataBrokerClient _brokerClient; + private readonly IDepartmentsService _departmentsService; + private readonly UserManager _userManager; + + public DataProtectionController(IDepartmentDataProtectionService dataProtectionService, + IDepartmentLockService departmentLockService, IAdpSizingService sizingService, + IProtectedDataBrokerClient brokerClient, IDepartmentsService departmentsService, + UserManager userManager) + { + _dataProtectionService = dataProtectionService; + _departmentLockService = departmentLockService; + _sizingService = sizingService; + _brokerClient = brokerClient; + _departmentsService = departmentsService; + _userManager = userManager; + } + + public async Task Index() + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + var model = new DataProtectionIndexView(); + + var policy = await _dataProtectionService.GetPolicyByDepartmentIdAsync(DepartmentId, bypassCache: true); + model.State = policy == null ? DepartmentDataProtectionState.Disabled : (DepartmentDataProtectionState)policy.State; + model.MigrationWindowStartLocal = policy?.MigrationWindowStartLocal; + model.MigrationWindowEndLocal = policy?.MigrationWindowEndLocal; + model.MigrationWindowTimeZone = policy?.MigrationWindowTimeZone; + model.OffboardingEffectiveOn = policy?.OffboardingEffectiveOn?.ToString("f"); + + model.Preflight = await _dataProtectionService.GetEnrollmentPreflightAsync(DepartmentId, UserId); + model.IsManagingMember = model.Preflight.IsManagingMember; + + model.IsDepartmentLocked = await _departmentLockService.IsDepartmentLockedAsync(DepartmentId); + if (model.IsDepartmentLocked) + model.LockReason = (await _departmentLockService.GetActiveLockAsync(DepartmentId))?.Reason; + + model.BrokerHealthy = await _brokerClient.IsHealthyAsync(); + + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + if (department != null && !string.IsNullOrWhiteSpace(department.ManagingUserId)) + { + var managingUser = await _userManager.FindByIdAsync(department.ManagingUserId); + model.ManagingMemberHasMfa = managingUser != null && await _userManager.GetTwoFactorEnabledAsync(managingUser); + } + + model.DefaultWindowStart = Config.DataProtectionConfig.MigrationWindowDefaultStartLocal; + model.DefaultWindowEnd = Config.DataProtectionConfig.MigrationWindowDefaultEndLocal; + model.TimeZones = TimeZoneInfo.GetSystemTimeZones() + .Select(tz => new SelectListItem + { + Value = tz.Id, + Text = tz.DisplayName, + Selected = string.Equals(tz.Id, department?.TimeZone, StringComparison.OrdinalIgnoreCase) + }) + .ToList(); + + return View(model); + } + + /// Wizard step 5: read-only sizing scan and the P50–P90 estimate (plan 18.2). + [HttpGet] + public async Task SizingScan(int windowMinutes, CancellationToken cancellationToken) + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + var minutes = windowMinutes is > 0 and <= 24 * 60 ? windowMinutes : 8 * 60; + var result = await _sizingService.RunSizingScanAsync(DepartmentId, minutes, cancellationToken); + return Json(result); + } + + /// + /// Wizard step 8: final confirmation and queueing. The acknowledgement record persisted on + /// the policy embeds the version, every acknowledged item, the lock consent, and a FRESH + /// server-side sizing scan (the client-shown estimate is advisory; the record's numbers are + /// authoritative). QueueEnrollmentAsync re-verifies managing member, paid plan, active + /// addon, the global gate and the window time zone at commit. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [RequiresRecentTwoFactor(RequireForOperation = true)] + public async Task QueueEnrollment([FromForm] QueueEnrollmentInputModel input, + CancellationToken cancellationToken) + { + if (input == null) + return BadRequest(); + + var missing = AckItems.Where(item => input.AcknowledgedItems == null || + !input.AcknowledgedItems.Contains(item, StringComparer.Ordinal)).ToList(); + if (missing.Count > 0) + return Json(new { success = false, error = "acknowledgements_incomplete" }); + + if (!input.LockConsent) + return Json(new { success = false, error = "lock_consent_required" }); + + AdpSizingResult sizing = null; + try + { + sizing = await _sizingService.RunSizingScanAsync(DepartmentId, 8 * 60, cancellationToken); + } + catch (Exception ex) + { + // The record survives without an estimate; the worker re-runs sizing on execution night. + Framework.Logging.LogException(ex, $"ADP wizard sizing scan failed for department {DepartmentId} at queue time"); + } + + var acknowledgementsJson = JsonConvert.SerializeObject(new + { + version = AcknowledgementVersion, + acknowledgedItems = AckItems, + lockConsent = true, + acknowledgedOnUtc = DateTime.UtcNow, + sizing + }); + + var outcome = await _dataProtectionService.QueueEnrollmentAsync(DepartmentId, UserId, + acknowledgementsJson, input.WindowStartLocal, input.WindowEndLocal, input.WindowTimeZone, + cancellationToken); + + return MapOutcome(outcome); + } + + /// Dequeues a not-yet-started enrollment at no data cost. Managing member only (service-enforced). + [HttpPost] + [ValidateAntiForgeryToken] + [AllowDuringDepartmentLock] + [RequiresRecentTwoFactor(RequireForOperation = true)] + public async Task CancelQueuedEnrollment(CancellationToken cancellationToken) + { + var outcome = await _dataProtectionService.CancelQueuedEnrollmentAsync(DepartmentId, UserId, cancellationToken); + return MapOutcome(outcome); + } + + /// + /// Revokes a scheduled offboarding before the first offboarding window opens. Allowed during + /// a department lock — the revoke window must not be blocked by an unrelated migration night. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [AllowDuringDepartmentLock] + [RequiresRecentTwoFactor(RequireForOperation = true)] + public async Task RevokeOffboarding(CancellationToken cancellationToken) + { + var outcome = await _dataProtectionService.RevokeOffboardingAsync(DepartmentId, UserId, cancellationToken); + return MapOutcome(outcome); + } + + private IActionResult MapOutcome(DepartmentDataProtectionEnrollmentResult outcome) + { + if (outcome == DepartmentDataProtectionEnrollmentResult.Queued) + return Json(new { success = true }); + + // Value-free codes matching the v4 API's problem types; the wizard maps them to text. + var error = outcome switch + { + DepartmentDataProtectionEnrollmentResult.NotManagingMember => "protected_access_denied", + DepartmentDataProtectionEnrollmentResult.AddonRequired => "addon_required", + DepartmentDataProtectionEnrollmentResult.PlanRequired => "plan_required", + DepartmentDataProtectionEnrollmentResult.FeatureNotAvailable => "feature_not_available", + DepartmentDataProtectionEnrollmentResult.InvalidState => "invalid_state", + DepartmentDataProtectionEnrollmentResult.InvalidWindow => "invalid_window", + _ => "command_failed" + }; + + return Json(new { success = false, error }); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs index b9f0c8a15..49ea12e81 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SecurityController.cs @@ -426,6 +426,54 @@ public async Task Index() commandAppLoginPermissions.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); model.CommandAppLoginPermissions = new SelectList(commandAppLoginPermissions, "Id", "Name"); + // ── Advanced Data Protection (ADP) permissions ───────────────────────────── + // Missing rows resolve through AdpPermissionDefaults, NOT the wide-open no-row + // convention — the preselected value here is exactly what enforcement will use. + int AdpValue(PermissionTypes type) => + permissions.Any(x => x.PermissionType == (int)type) + ? permissions.First(x => x.PermissionType == (int)type).Action + : (int)AdpPermissionDefaults.For(type); + + SelectList AdpOptions(bool includeEveryone) + { + var options = new List(); + if (includeEveryone) + options.Add(new { Id = 3, Name = "Everyone" }); + options.Add(new { Id = 0, Name = "Department Admins" }); + options.Add(new { Id = 1, Name = "Department and Group Admins" }); + options.Add(new { Id = 2, Name = "Department Admins and Select Roles" }); + return new SelectList(options, "Id", "Name"); + } + + model.ManageDataProtection = AdpValue(PermissionTypes.ManageDepartmentDataProtection); + model.ManageDataProtectionPermissions = AdpOptions(includeEveryone: false); + + model.ViewProtectedCallData = AdpValue(PermissionTypes.ViewProtectedCallData); + model.ViewProtectedCallDataPermissions = AdpOptions(includeEveryone: true); + + model.EditProtectedCallData = AdpValue(PermissionTypes.EditProtectedCallData); + model.EditProtectedCallDataPermissions = AdpOptions(includeEveryone: true); + + model.ViewProtectedPersonnelData = AdpValue(PermissionTypes.ViewProtectedPersonnelData); + model.ViewProtectedPersonnelDataPermissions = AdpOptions(includeEveryone: true); + + model.ViewProtectedContactData = AdpValue(PermissionTypes.ViewProtectedContactData); + model.ViewProtectedContactDataPermissions = AdpOptions(includeEveryone: true); + + model.ViewProtectedOperationalData = AdpValue(PermissionTypes.ViewProtectedOperationalData); + model.ViewProtectedOperationalDataPermissions = AdpOptions(includeEveryone: true); + + // Export, egress and break-glass never offer "Everyone": exports leave the system, + // egress reconfiguration widens disclosure, and break-glass is an audited emergency + // path that additionally requires the department policy to enable it at all. + model.ExportProtectedData = AdpValue(PermissionTypes.ExportProtectedData); + model.ExportProtectedDataPermissions = AdpOptions(includeEveryone: false); + + model.ConfigureProtectedDataEgress = AdpValue(PermissionTypes.ConfigureProtectedDataEgress); + model.ConfigureProtectedDataEgressPermissions = AdpOptions(includeEveryone: false); + + model.BreakGlassProtectedData = AdpValue(PermissionTypes.BreakGlassProtectedData); + model.BreakGlassProtectedDataPermissions = AdpOptions(includeEveryone: false); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); model.IsManagingUser = department.ManagingUserId == UserId; @@ -548,7 +596,8 @@ public async Task ViewAudit(int auditLogId) /// Sets the department-level 2FA enforcement scope. Only the managing user (owner) may change this. /// scope: 0=disabled, 1=dept admins+managing user, 2=also group admins /// - [HttpGet] + [HttpPost] + [ValidateAntiForgeryToken] [RequiresRecentTwoFactor] public async Task Set2FARequirement(int scope, CancellationToken cancellationToken) { @@ -589,7 +638,10 @@ public async Task Set2FARequirement(int scope, CancellationToken return new StatusCodeResult((int)HttpStatusCode.OK); } - [HttpGet] + // POST + antiforgery: permission changes are state-changing and must never be reachable by a + // cross-site top-level GET navigation riding the SameSite=Lax auth cookie. + [HttpPost] + [ValidateAntiForgeryToken] [RequiresRecentTwoFactor] public async Task SetPermission(int type, int perm, bool? lockToGroup) { @@ -644,7 +696,8 @@ public async Task SetPermission(int type, int perm, bool? lockToG return new StatusCodeResult((int)HttpStatusCode.NotModified); } - [HttpGet] + [HttpPost] + [ValidateAntiForgeryToken] public async Task SetPermissionData(int type, string data, bool? lockToGroup) { if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) @@ -699,6 +752,7 @@ public async Task SetPermissionData(int type, string data, bool? return new StatusCodeResult((int)HttpStatusCode.NotModified); } + [HttpGet] public async Task GetRolesForPermission(int type) { var before = await _permissionsService.GetPermissionByDepartmentTypeAsync(DepartmentId, (PermissionTypes)type); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs b/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs index 5152e061c..46c68371f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/SubscriptionController.cs @@ -27,6 +27,10 @@ namespace Resgrid.Web.Areas.User.Controllers { [Area("User")] [ClaimsResource(ResgridClaimTypes.Resources.Department)] + // Billing must stay available while an ADP migration window holds the department operation + // lock: the managing member manages the addon (and can revoke a scheduled offboarding) here, + // and billing state is not department operational data (plan sections 17, 20.2). + [Resgrid.Web.Filters.AllowDuringDepartmentLock] public class SubscriptionController : SecureBaseController { #region Private Members and Constructors diff --git a/Web/Resgrid.Web/Areas/User/Models/DataProtection/DataProtectionIndexView.cs b/Web/Resgrid.Web/Areas/User/Models/DataProtection/DataProtectionIndexView.cs new file mode 100644 index 000000000..c01bc6367 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/DataProtection/DataProtectionIndexView.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.Rendering; +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.DataProtection +{ + /// + /// View model for the ADP status page / Enrollment Wizard (plan sections 3.5, 12, 18). Values + /// are ADVISORY for rendering only — every command is re-verified server-side (managing member, + /// addon, gate, MFA recency) when it executes. + /// + public class DataProtectionIndexView + { + public DepartmentDataProtectionState State { get; set; } + + public string StateName => State.ToString(); + + public bool IsManagingMember { get; set; } + + public AdpEnrollmentPreflight Preflight { get; set; } = new AdpEnrollmentPreflight(); + + /// Shallow broker /health probe result (wizard preflight step 4). + public bool BrokerHealthy { get; set; } + + /// The managing member has an authenticator enrolled (step-up depends on it). + public bool ManagingMemberHasMfa { get; set; } + + public string MigrationWindowStartLocal { get; set; } + + public string MigrationWindowEndLocal { get; set; } + + public string MigrationWindowTimeZone { get; set; } + + public string OffboardingEffectiveOn { get; set; } + + public bool IsDepartmentLocked { get; set; } + + public string LockReason { get; set; } + + /// System time zones for the window-selection step. + public List TimeZones { get; set; } = new List(); + + /// Department-local default window ("22:00"/"06:00" unless operator-tuned). + public string DefaultWindowStart { get; set; } + + public string DefaultWindowEnd { get; set; } + } + + /// POST body for the wizard's final queue step. Acknowledgements must ALL be true. + public class QueueEnrollmentInputModel + { + public string WindowStartLocal { get; set; } + + public string WindowEndLocal { get; set; } + + public string WindowTimeZone { get; set; } + + /// Every section 12 disclosure item, individually acknowledged (see AckItems). + public List AcknowledgedItems { get; set; } = new List(); + + /// Explicit consent to the department operation lock (section 18.1 step 7). + public bool LockConsent { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Security/PermissionsView.cs b/Web/Resgrid.Web/Areas/User/Models/Security/PermissionsView.cs index e8120f625..a15faa74b 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Security/PermissionsView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Security/PermissionsView.cs @@ -100,6 +100,35 @@ public class PermissionsView public int CommandAppLogin { get; set; } public SelectList CommandAppLoginPermissions { get; set; } + // Advanced Data Protection (ADP) permissions. Defaults come from + // Resgrid.Model.AdpPermissionDefaults — deliberately NOT the wide-open no-row convention. + public int ManageDataProtection { get; set; } + public SelectList ManageDataProtectionPermissions { get; set; } + + public int ViewProtectedCallData { get; set; } + public SelectList ViewProtectedCallDataPermissions { get; set; } + + public int EditProtectedCallData { get; set; } + public SelectList EditProtectedCallDataPermissions { get; set; } + + public int ViewProtectedPersonnelData { get; set; } + public SelectList ViewProtectedPersonnelDataPermissions { get; set; } + + public int ViewProtectedContactData { get; set; } + public SelectList ViewProtectedContactDataPermissions { get; set; } + + public int ViewProtectedOperationalData { get; set; } + public SelectList ViewProtectedOperationalDataPermissions { get; set; } + + public int ExportProtectedData { get; set; } + public SelectList ExportProtectedDataPermissions { get; set; } + + public int ConfigureProtectedDataEgress { get; set; } + public SelectList ConfigureProtectedDataEgressPermissions { get; set; } + + public int BreakGlassProtectedData { get; set; } + public SelectList BreakGlassProtectedDataPermissions { get; set; } + // Two-Factor Authentication enforcement public int Require2FAForAdmins { get; set; } public SelectList Require2FAForAdminsOptions { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml new file mode 100644 index 000000000..dc301c4bd --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml @@ -0,0 +1,284 @@ +@using Resgrid.Model +@using Resgrid.Web.Helpers +@model Resgrid.Web.Areas.User.Models.DataProtection.DataProtectionIndexView +@{ + ViewBag.Title = "Resgrid | Advanced Data Protection"; +} + +@Html.AntiForgeryToken() + +
+
+

Advanced Data Protection

+ +
+
+ +
+ + @if (Model.IsDepartmentLocked) + { +
+ Migration window active. @(string.IsNullOrWhiteSpace(Model.LockReason) ? "Data entry is paused until the window closes; viewing is unaffected." : Model.LockReason) +
+ } + + @* ── Status panel (always shown) ─────────────────────────────────────── *@ +
+
+
+
+
Protection Status
+
+
+

+ Current state: + + @Model.StateName + +

+ + @switch (Model.State) + { + case DepartmentDataProtectionState.Disabled: + if (!Model.Preflight.GateOpen) + { +
+ Advanced Data Protection enrollment is temporarily unavailable. If your department has already purchased the addon, it remains valid — check back soon. +
+ } + else if (!Model.Preflight.HasActiveAddon) + { +
+

+ Advanced Data Protection encrypts your department's sensitive dispatch, personnel and contact data with keys owned by your department. It requires the yearly ADP addon. +

+ @if (Model.IsManagingMember) + { + Purchase the ADP addon + } + else + { +

Only your department's managing member can purchase the addon and enroll.

+ } +
+ } + else if (!Model.IsManagingMember) + { +
+ The ADP addon is active. Only your department's managing member can complete enrollment. +
+ } + break; + + case DepartmentDataProtectionState.EnrollmentQueued: +
+

+ Enrollment is queued. Migration runs during your selected overnight window + (@Model.MigrationWindowStartLocal–@Model.MigrationWindowEndLocal, @Model.MigrationWindowTimeZone). + You will receive an email when each night's window opens and closes. +

+ @if (Model.IsManagingMember) + { + + } +
+ break; + + case DepartmentDataProtectionState.ProvisioningKey: + case DepartmentDataProtectionState.Encrypting: + case DepartmentDataProtectionState.Verifying: + case DepartmentDataProtectionState.Rotating: +
+ Migration is in progress. Work happens only inside your overnight window + (@Model.MigrationWindowStartLocal–@Model.MigrationWindowEndLocal, @Model.MigrationWindowTimeZone); + your department is in full service outside it. +
+ break; + + case DepartmentDataProtectionState.Enabled: +
+

Advanced Data Protection is active. Protected fields are encrypted with your department's keys; viewing them requires a signed-in member with a fresh authenticator verification.

+

+ To turn ADP off, cancel the addon on the + subscription page. + Protection stays active until the end of your current billing period. After your data is decrypted, turning it back on requires purchasing the addon again and completing a new enrollment. +

+
+ break; + + case DepartmentDataProtectionState.OffboardingScheduled: +
+

+ Offboarding is scheduled. Protection remains fully active until + @(string.IsNullOrWhiteSpace(Model.OffboardingEffectiveOn) ? "the end of your billing period" : Model.OffboardingEffectiveOn), + when your data is decrypted back to standard storage over one or more overnight windows. +

+

+ Advanced Data Protection stays active until the end of your current billing period. After your data is decrypted, turning it back on requires purchasing the addon again and completing a new enrollment. +

+ @if (Model.IsManagingMember) + { + + } +
+ break; + + case DepartmentDataProtectionState.DisableRequested: + case DepartmentDataProtectionState.Decrypting: +
+ Offboarding is in progress. Protection remains in effect until your data is fully restored to standard storage. +
+ break; + + case DepartmentDataProtectionState.Failed: +
+ The last migration run could not complete and will resume after review. Your department is in full service and your data remains safe. Support has been alerted; no action is needed. +
+ break; + } +
+
+
+
+
+ + @* ── Enrollment Wizard (Disabled + gate open + addon + managing member) ── *@ + @if (Model.State == DepartmentDataProtectionState.Disabled && Model.Preflight.GateOpen && + Model.Preflight.HasActiveAddon && Model.IsManagingMember) + { +
+
+
+
Enrollment Wizard
+
+ + @* Step 1: Introduction and scope *@ +
+

Step 1 of 6 — What Advanced Data Protection covers

+

ADP encrypts the sensitive content of your department's data with keys owned by your department and held in a hardened key service:

+
    +
  • Protected: call names, natures, notes and addresses; call notes and attachments; contact details and notes; member sensitive data.
  • +
  • Stays plaintext: system identifiers, call numbers, priorities, statuses, timestamps, unit names and department structure — the platform needs these to route and display work.
  • +
+

While protected:

+
    +
  • Search, reporting, exports and third-party integrations cannot see protected content.
  • +
  • Big Board displays show a reduced "protected incident" shell instead of call details.
  • +
  • Workflows receive redacted payloads (protected values replaced with REDACTED).
  • +
  • Text, email, push and voice notifications send generic content by default ("A protected dispatch is available — sign in to Resgrid").
  • +
+
+ Advanced Data Protection alone is not HIPAA or ePCR compliance. It is one control inside a compliance program your agency still owns. +
+ +
+ + @* Step 2: Acknowledgements *@ + + + @* Step 3: Preflight *@ + + + @* Step 4: Sizing scan *@ + + + @* Step 5: Window selection + lock consent *@ + + + @* Step 6: Confirm and queue *@ + + +
+
+
+
+ } +
+ +@section Scripts +{ + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml index 291143291..30dce1dab 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Security/Index.cshtml @@ -7,6 +7,11 @@ ViewBag.Title = "Resgrid | " + localizer["SecurityPermissionsHeader"]; } +@* Antiforgery token for the permission POSTs in resgrid.security.permissions.js and the + 2FA-enforcement script below — SetPermission/SetPermissionData/Set2FARequirement are + state-changing and require it. *@ +@Html.AntiForgeryToken() +

@localizer["SecurityPermissionsHeader"]

@@ -23,6 +28,9 @@ {
+ + Data Protection + SSO & SCIM @@ -343,6 +351,99 @@ + + @localizer["PermAdpSectionHeader"]
@localizer["PermAdpSectionNote"] + + + @localizer["PermAdpManageLabel"] + @localizer["PermAdpManageNote"] + @Html.DropDownListFor(m => m.ManageDataProtection, Model.ManageDataProtectionPermissions) + @localizer["PermissionNA"] + + @localizer["PermissionNoRoles"] + + + + + @localizer["PermAdpViewCallLabel"] + @localizer["PermAdpViewCallNote"] + @Html.DropDownListFor(m => m.ViewProtectedCallData, Model.ViewProtectedCallDataPermissions) + @localizer["PermissionNA"] + + @localizer["PermissionNoRoles"] + + + + + @localizer["PermAdpEditCallLabel"] + @localizer["PermAdpEditCallNote"] + @Html.DropDownListFor(m => m.EditProtectedCallData, Model.EditProtectedCallDataPermissions) + @localizer["PermissionNA"] + + @localizer["PermissionNoRoles"] + + + + + @localizer["PermAdpViewPersonnelLabel"] + @localizer["PermAdpViewPersonnelNote"] + @Html.DropDownListFor(m => m.ViewProtectedPersonnelData, Model.ViewProtectedPersonnelDataPermissions) + @localizer["PermissionNA"] + + @localizer["PermissionNoRoles"] + + + + + @localizer["PermAdpViewContactLabel"] + @localizer["PermAdpViewContactNote"] + @Html.DropDownListFor(m => m.ViewProtectedContactData, Model.ViewProtectedContactDataPermissions) + @localizer["PermissionNA"] + + @localizer["PermissionNoRoles"] + + + + + @localizer["PermAdpViewOperationalLabel"] + @localizer["PermAdpViewOperationalNote"] + @Html.DropDownListFor(m => m.ViewProtectedOperationalData, Model.ViewProtectedOperationalDataPermissions) + @localizer["PermissionNA"] + + @localizer["PermissionNoRoles"] + + + + + @localizer["PermAdpExportLabel"] + @localizer["PermAdpExportNote"] + @Html.DropDownListFor(m => m.ExportProtectedData, Model.ExportProtectedDataPermissions) + @localizer["PermissionNA"] + + @localizer["PermissionNoRoles"] + + + + + @localizer["PermAdpEgressLabel"] + @localizer["PermAdpEgressNote"] + @Html.DropDownListFor(m => m.ConfigureProtectedDataEgress, Model.ConfigureProtectedDataEgressPermissions) + @localizer["PermissionNA"] + + @localizer["PermissionNoRoles"] + + + + + @localizer["PermAdpBreakGlassLabel"] + @localizer["PermAdpBreakGlassNote"] + @Html.DropDownListFor(m => m.BreakGlassProtectedData, Model.BreakGlassProtectedDataPermissions) + @localizer["PermissionNA"] + + @localizer["PermissionNoRoles"] + + +
@@ -434,7 +535,11 @@ $(function () { $('#require2FADropdown').change(function () { var scope = $(this).val(); - $.get('@Url.Action("Set2FARequirement", "Security", new { area = "User" })?scope=' + scope, function (data) { + $.ajax({ + url: '@Url.Action("Set2FARequirement", "Security", new { area = "User" })?scope=' + scope, + type: 'POST', + headers: { 'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').first().val() } + }).done(function (data) { toastr.success('@localizer["Require2FASettingSaved"]'); }).fail(function (xhr) { if (xhr.status === 412) { diff --git a/Web/Resgrid.Web/Filters/AllowDuringDepartmentLockAttribute.cs b/Web/Resgrid.Web/Filters/AllowDuringDepartmentLockAttribute.cs new file mode 100644 index 000000000..7a617ef25 --- /dev/null +++ b/Web/Resgrid.Web/Filters/AllowDuringDepartmentLockAttribute.cs @@ -0,0 +1,15 @@ +using System; + +namespace Resgrid.Web.Filters +{ + /// + /// Exempts an MVC action (or a whole controller) from + /// mutation blocking. Use ONLY for flows that must stay available while a department operation + /// lock is active: authentication/session, billing/subscription management, and the ADP + /// lock-abort path itself — the abort can never be blocked by the very lock it releases. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false, Inherited = true)] + public sealed class AllowDuringDepartmentLockAttribute : Attribute + { + } +} diff --git a/Web/Resgrid.Web/Filters/DepartmentLockActionFilter.cs b/Web/Resgrid.Web/Filters/DepartmentLockActionFilter.cs new file mode 100644 index 000000000..6955903cd --- /dev/null +++ b/Web/Resgrid.Web/Filters/DepartmentLockActionFilter.cs @@ -0,0 +1,87 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Resgrid.Framework; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Filters +{ + /// + /// MVC twin of the API department-operation-lock gate (ADP plan section 20.2): while a + /// department's lock is active, authenticated mutating requests (POST/PUT/PATCH/DELETE) for + /// that department are refused with 423 Locked and a plain explanation; GET/reads continue so + /// every page stays viewable. Department identity comes from the signed authentication state + /// (ClaimTypes.PrimaryGroupSid), never the form body. Opt out with + /// for auth, billing, and the lock-abort path. + /// Fails OPEN on a lock-store fault — dispatch availability beats migration progress. + /// + public sealed class DepartmentLockActionFilter : IAsyncActionFilter + { + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + var method = context.HttpContext.Request.Method; + var isMutation = HttpMethods.IsPost(method) || HttpMethods.IsPut(method) || + HttpMethods.IsPatch(method) || HttpMethods.IsDelete(method); + + var departmentClaim = context.HttpContext.User?.FindFirst(ClaimTypes.PrimaryGroupSid)?.Value; + + if (!isMutation || HasAllowAttribute(context) || + !int.TryParse(departmentClaim, out var departmentId) || departmentId <= 0) + { + await next(); + return; + } + + var lockService = context.HttpContext.RequestServices.GetService(); + + var isLocked = false; + string reason = null; + if (lockService != null) + { + try + { + isLocked = await lockService.IsDepartmentLockedAsync(departmentId); + if (isLocked) + reason = (await lockService.GetActiveLockAsync(departmentId))?.Reason; + } + catch (Exception ex) + { + Logging.LogException(ex, $"DepartmentLockActionFilter (web) failed for department {departmentId}; failing open"); + isLocked = false; + } + } + + if (!isLocked) + { + await next(); + return; + } + + context.Result = new ContentResult + { + StatusCode = StatusCodes.Status423Locked, + ContentType = "text/plain", + Content = reason ?? "A maintenance operation is in progress; data entry is paused. Viewing is unaffected — please try again after the maintenance window." + }; + } + + private static bool HasAllowAttribute(ActionExecutingContext context) + { + // This app runs legacy MVC routing (EnableEndpointRouting = false), where + // EndpointMetadata is not reliably populated — read the attributes off the action and + // controller directly. + if (context.ActionDescriptor is Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor descriptor) + { + return descriptor.MethodInfo.GetCustomAttributes(typeof(AllowDuringDepartmentLockAttribute), inherit: true).Any() || + descriptor.ControllerTypeInfo.GetCustomAttributes(typeof(AllowDuringDepartmentLockAttribute), inherit: true).Any(); + } + + return false; + } + } +} diff --git a/Web/Resgrid.Web/Resgrid.Web.csproj b/Web/Resgrid.Web/Resgrid.Web.csproj index 1b1ed247a..ade71f03c 100644 --- a/Web/Resgrid.Web/Resgrid.Web.csproj +++ b/Web/Resgrid.Web/Resgrid.Web.csproj @@ -168,6 +168,7 @@ + diff --git a/Web/Resgrid.Web/Startup.cs b/Web/Resgrid.Web/Startup.cs index 727bbd84c..ca49aa264 100644 --- a/Web/Resgrid.Web/Startup.cs +++ b/Web/Resgrid.Web/Startup.cs @@ -436,6 +436,9 @@ public void ConfigureServices(IServiceCollection services) { options.EnableEndpointRouting = false; options.Filters.Add(new Microsoft.AspNetCore.Mvc.ServiceFilterAttribute(typeof(Filters.RequireActivePlanFilter))); + // ADP department operation lock: refuses department-scoped mutations with 423 Locked + // while a migration window holds the department's lock; reads pass through untouched. + options.Filters.Add(); }).AddJsonOptions(jsonOptions => { jsonOptions.JsonSerializerOptions.PropertyNamingPolicy = null; @@ -528,6 +531,9 @@ public void ConfigureContainer(ContainerBuilder builder) builder.RegisterModule(new MessagingProviderModule()); builder.RegisterModule(new Resgrid.Providers.Workflow.WorkflowProviderModule()); builder.RegisterModule(new Resgrid.Providers.Weather.WeatherProviderModule()); + // ADP broker CLIENT only (no key material, no KMS route) — the wizard preflight probes + // broker health through it. The real KMS adapter module is broker-host-only. + builder.RegisterModule(new Resgrid.Providers.ProtectedData.ProtectedDataBrokerClientModule()); // Chatbot per-department configuration service (its repository, cache and encryption deps // are provided by DataModule / CacheProviderModule / ServicesModule above). diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.dataprotection.wizard.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.dataprotection.wizard.js new file mode 100644 index 000000000..15d37b5e8 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.dataprotection.wizard.js @@ -0,0 +1,147 @@ + +var resgrid; +(function (resgrid) { + var dataprotection; + (function (dataprotection) { + var wizard; + (function (wizard) { + // Every command is a state-changing POST carrying the page's antiforgery token; the + // server re-verifies managing member, addon, gate, MFA recency and state on each one. + function antiForgeryToken() { + return $('input[name="__RequestVerificationToken"]').first().val(); + } + + function post(url, data) { + return $.ajax({ + url: resgrid.absoluteBaseUrl + url, + type: 'POST', + data: data, + headers: { 'RequestVerificationToken': antiForgeryToken() } + }); + } + + var errorText = { + 'acknowledgements_incomplete': 'Every acknowledgement must be checked before enrollment can be queued.', + 'lock_consent_required': 'The overnight operation pause must be consented to before enrollment can be queued.', + 'protected_access_denied': 'Only the department\'s managing member may run this command.', + 'addon_required': 'An active Advanced Data Protection addon is required.', + 'plan_required': 'Advanced Data Protection requires a paid plan.', + 'feature_not_available': 'Advanced Data Protection enrollment is temporarily unavailable.', + 'invalid_state': 'The department\'s protection state does not permit this command. Reload the page for current status.', + 'invalid_window': 'A valid migration window time zone is required.', + 'command_failed': 'The command could not be completed; it may be retried.' + }; + + function showError(container, code) { + $(container).html('
' + (errorText[code] || errorText['command_failed']) + '
'); + } + + $(document).ready(function () { + // ── Wizard step navigation ────────────────────────────────────── + function goTo(step) { + $('.adp-step').hide(); + $('.adp-step[data-step="' + step + '"]').show(); + } + + $('.adp-next').click(function () { + if ($(this).is(':disabled')) + return; + var current = parseInt($(this).closest('.adp-step').data('step'), 10); + goTo(current + 1); + }); + $('.adp-prev').click(function () { + var current = parseInt($(this).closest('.adp-step').data('step'), 10); + goTo(current - 1); + }); + + // Step 2: continue only when every acknowledgement is checked. + $('.adp-ack').change(function () { + var allChecked = $('.adp-ack').length === $('.adp-ack:checked').length; + $('#adpAckNext').prop('disabled', !allChecked); + }); + + // Step 5: continue only with explicit lock consent. + $('#adpLockConsent').change(function () { + $('#adpLockNext').prop('disabled', !this.checked); + }); + + // Step 4: read-only sizing scan. + $('#adpRunSizing').click(function () { + var btn = $(this); + btn.prop('disabled', true); + $('#adpSizingResult').html('Scanning… this reads row counts only and changes nothing.'); + + $.ajax({ + url: resgrid.absoluteBaseUrl + '/User/DataProtection/SizingScan?windowMinutes=480', + type: 'GET' + }).done(function (result) { + var rows = ''; + if (result.TableRowCounts) { + Object.keys(result.TableRowCounts).forEach(function (table) { + rows += '' + table + '' + result.TableRowCounts[table].toLocaleString() + ''; + }); + } + $('#adpSizingResult').html( + '' + rows + '
TableRows
' + + '

' + Number(result.TotalRows).toLocaleString() + ' rows total. Estimated migration time: ' + + '' + result.EstimatedP50Minutes + '–' + result.EstimatedP90Minutes + ' minutes, projected across ' + + '' + result.ProjectedNights + ' overnight window(s). The estimate is a range, not a promise; the migration checkpoints every night and your department is in full service between windows.

'); + }).fail(function () { + $('#adpSizingResult').html('
The sizing scan could not run. You can retry, or continue — the migration worker re-checks sizing on execution night.
'); + }).always(function () { + btn.prop('disabled', false); + }); + }); + + // Step 6: final queue. The server rebuilds the acknowledgement record and re-runs + // every gate; this just reports the outcome. + $('#adpQueueEnrollment').click(function () { + var btn = $(this); + btn.prop('disabled', true); + $('#adpQueueError').empty(); + + var data = { + WindowStartLocal: $('#adpWindowStart').val(), + WindowEndLocal: $('#adpWindowEnd').val(), + WindowTimeZone: $('#adpWindowTimeZone').val(), + LockConsent: $('#adpLockConsent').is(':checked'), + AcknowledgedItems: $('.adp-ack:checked').map(function () { return this.value; }).get() + }; + + post('/User/DataProtection/QueueEnrollment', data).done(function (result) { + if (result.success) { + window.location.reload(); + } else { + showError('#adpQueueError', result.error); + btn.prop('disabled', false); + } + }).fail(function () { + showError('#adpQueueError', 'command_failed'); + btn.prop('disabled', false); + }); + }); + + // ── Status-panel commands ─────────────────────────────────────── + $('#btnCancelQueued').click(function () { + if (!window.confirm('Cancel the queued enrollment? Nothing has been migrated yet; you can enroll again later while the addon is active.')) + return; + var btn = $(this).prop('disabled', true); + post('/User/DataProtection/CancelQueuedEnrollment').done(function (result) { + if (result.success) { window.location.reload(); } + else { showError('#commandResult', result.error); btn.prop('disabled', false); } + }).fail(function () { showError('#commandResult', 'command_failed'); btn.prop('disabled', false); }); + }); + + $('#btnRevokeOffboarding').click(function () { + if (!window.confirm('Keep Advanced Data Protection active? The scheduled offboarding will be cancelled.')) + return; + var btn = $(this).prop('disabled', true); + post('/User/DataProtection/RevokeOffboarding').done(function (result) { + if (result.success) { window.location.reload(); } + else { showError('#commandResult', result.error); btn.prop('disabled', false); } + }).fail(function () { showError('#commandResult', 'command_failed'); btn.prop('disabled', false); }); + }); + }); + })(wizard = dataprotection.wizard || (dataprotection.wizard = {})); + })(dataprotection = resgrid.dataprotection || (resgrid.dataprotection = {})); +})(resgrid || (resgrid = {})); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.permissions.js b/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.permissions.js index b6dee7ea6..6b6c28d44 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.permissions.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/security/resgrid.security.permissions.js @@ -5,6 +5,11 @@ var resgrid; (function (security) { var permissions; (function (permissions) { + // Every SetPermission/SetPermissionData call is a state-changing POST carrying the + // page's antiforgery token (rendered by the Security Index view). Reads stay GET. + function antiForgeryToken() { + return $('input[name="__RequestVerificationToken"]').first().val(); + } function initPermRoles(selector, permType) { $(selector).select2({ placeholder: "Select roles...", @@ -21,7 +26,8 @@ var resgrid; $(selector).on('change', function () { $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermissionData?type=' + permType + '&data=' + encodeURIComponent(($(selector).val() || []).join(',')), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }); }); $.ajax({ @@ -42,7 +48,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=0&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); }); @@ -50,7 +57,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=1&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); }); @@ -66,7 +74,8 @@ var resgrid; } $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=2&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); }); @@ -83,7 +92,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=3&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#CreateTraining").val() === "2") { @@ -108,7 +118,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=4&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#CreateDocument").val() === "2") { @@ -133,7 +144,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=5&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#CreateCalendarEntry").val() === "2") { @@ -158,7 +170,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=6&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#CreateNote").val() === "2") { @@ -183,7 +196,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=7&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#CreateLog").val() === "2") { @@ -211,7 +225,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=27&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#DeleteLog").val() === "2") { @@ -238,7 +253,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=8&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#CreateShift").val() === "2") { @@ -263,7 +279,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=9&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#ViewPersonalInfo").val() === "2") { @@ -288,7 +305,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=10&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#AdjustInventory").val() === "2") { @@ -313,7 +331,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=11&perm=' + val + '&lockToGroup=' + $('#LockViewPersonneLocationToGroup').is(':checked'), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#ViewPersonnelLocation").val() === "2") { @@ -337,7 +356,8 @@ var resgrid; $('#LockViewPersonneLocationToGroup').change(function () { $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=11&perm=' + $('#ViewPersonnelLocation').val() + '&lockToGroup=' + $('#LockViewPersonneLocationToGroup').is(':checked'), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); }); @@ -347,7 +367,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=12&perm=' + val + '&lockToGroup=' + $('#LockViewPersonneLocationToGroup').is(':checked'), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#ViewUnitLocation").val() === "2") { @@ -371,7 +392,8 @@ var resgrid; $('#LockViewUnitLocationToGroup').change(function () { $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=12&perm=' + $('#ViewUnitLocation').val() + '&lockToGroup=' + $('#LockViewUnitLocationToGroup').is(':checked'), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); }); @@ -382,7 +404,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=13&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#CreateMessage").val() === "2") { @@ -412,7 +435,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=14&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#ViewGroupsUsers").val() === "2") { @@ -435,7 +459,8 @@ var resgrid; $('#LockViewGroupsUsersToGroup').change(function () { $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=14&perm=' + $('#LockViewGroupsUsersToGroup').val() + '&lockToGroup=' + $('#LockViewGroupsUsersToGroup').is(':checked'), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); }); @@ -449,7 +474,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=15&perm=' + val + '&lockToGroup=' + $('#LockDeleteCallToGroup').is(':checked'), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#DeleteCall").val() === "2") { @@ -473,7 +499,8 @@ var resgrid; $('#LockDeleteCallToGroup').change(function () { $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=15&perm=' + $('#DeleteCall').val() + '&lockToGroup=' + $('#LockDeleteCallToGroup').is(':checked'), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); }); @@ -485,7 +512,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=16&perm=' + val + '&lockToGroup=' + $('#LockCloseCallToGroup').is(':checked'), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#CloseCall").val() === "2") { @@ -509,7 +537,8 @@ var resgrid; $('#LockCloseCallToGroup').change(function () { $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=16&perm=' + $('#CloseCall').val() + '&lockToGroup=' + $('#LockCloseCallToGroup').is(':checked'), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); }); @@ -521,7 +550,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=17&perm=' + val + '&lockToGroup=' + $('#LockAddCallDataToGroup').is(':checked'), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#AddCallData").val() === "2") { @@ -545,7 +575,8 @@ var resgrid; $('#LockAddCallDataToGroup').change(function () { $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=17&perm=' + $('#AddCallData').val() + '&lockToGroup=' + $('#LockAddCallDataToGroup').is(':checked'), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); }); @@ -557,7 +588,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=18&perm=' + val + '&lockToGroup=' + $('#LockViewGroupsUnitsToGroup').is(':checked'), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#ViewGroupsUnits").val() === "2") { @@ -581,7 +613,8 @@ var resgrid; $('#LockViewGroupsUnitsToGroup').change(function () { $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=18&perm=' + $('#ViewGroupsUnits').val() + '&lockToGroup=' + $('#LockViewGroupsUnitsToGroup').is(':checked'), - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); }); @@ -594,7 +627,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=20&perm=' + val + '&lockToGroup=false', - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#ViewContacts").val() === "2") { @@ -624,7 +658,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=19&perm=' + val + '&lockToGroup=false', - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#EditContacts").val() === "2") { @@ -654,7 +689,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=21&perm=' + val + '&lockToGroup=false', - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#DeleteContacts").val() === "2") { @@ -683,7 +719,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=22&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if (val === "2") { $('#workflowCreateNoRolesSpan').hide(); @@ -709,7 +746,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=23&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if (val === "2") { $('#workflowCredentialsNoRolesSpan').hide(); @@ -735,7 +773,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=24&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if (val === "2") { $('#workflowRunsNoRolesSpan').hide(); @@ -761,7 +800,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=28&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#UseCalendarSync").val() === "2") { @@ -788,7 +828,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=29&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#DispatchAppLogin").val() === "2") { @@ -815,7 +856,8 @@ var resgrid; var val = this.value; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=30&perm=' + val, - type: 'GET' + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } }).done(function (results) { }); if ($("#CommandAppLogin").val() === "2") { @@ -836,6 +878,43 @@ var resgrid; initPermRoles("#commandAppLoginRoles", 30); //////////////////////////////////////////////////////// + // Advanced Data Protection permissions (PermissionTypes 31-39) + //////////////////////////////////////////////////////// + var adpPermissions = [ + { sel: '#ManageDataProtection', type: 31, roles: '#adpManageRoles', span: '#adpManageNoRolesSpan', div: '#adpManageRolesDiv' }, + { sel: '#ViewProtectedCallData', type: 32, roles: '#adpViewCallRoles', span: '#adpViewCallNoRolesSpan', div: '#adpViewCallRolesDiv' }, + { sel: '#EditProtectedCallData', type: 33, roles: '#adpEditCallRoles', span: '#adpEditCallNoRolesSpan', div: '#adpEditCallRolesDiv' }, + { sel: '#ViewProtectedPersonnelData', type: 34, roles: '#adpViewPersonnelRoles', span: '#adpViewPersonnelNoRolesSpan', div: '#adpViewPersonnelRolesDiv' }, + { sel: '#ViewProtectedContactData', type: 35, roles: '#adpViewContactRoles', span: '#adpViewContactNoRolesSpan', div: '#adpViewContactRolesDiv' }, + { sel: '#ViewProtectedOperationalData', type: 36, roles: '#adpViewOperationalRoles', span: '#adpViewOperationalNoRolesSpan', div: '#adpViewOperationalRolesDiv' }, + { sel: '#ExportProtectedData', type: 37, roles: '#adpExportRoles', span: '#adpExportNoRolesSpan', div: '#adpExportRolesDiv' }, + { sel: '#ConfigureProtectedDataEgress', type: 38, roles: '#adpEgressRoles', span: '#adpEgressNoRolesSpan', div: '#adpEgressRolesDiv' }, + { sel: '#BreakGlassProtectedData', type: 39, roles: '#adpBreakGlassRoles', span: '#adpBreakGlassNoRolesSpan', div: '#adpBreakGlassRolesDiv' } + ]; + adpPermissions.forEach(function (p) { + var toggleRoles = function () { + if ($(p.sel).val() === "2") { + $(p.span).hide(); + $(p.div).show(); + } else { + $(p.span).show(); + $(p.div).hide(); + } + }; + $(p.sel).change(function () { + $.ajax({ + url: resgrid.absoluteBaseUrl + '/User/Security/SetPermission?type=' + p.type + '&perm=' + this.value, + type: 'POST', + headers: { 'RequestVerificationToken': antiForgeryToken() } + }).done(function (results) { + }); + toggleRoles(); + }); + toggleRoles(); + initPermRoles(p.roles, p.type); + }); + //////////////////////////////////////////////////////// + }); })(permissions = security.permissions || (security.permissions = {})); })(security = resgrid.security || (resgrid.security = {})); diff --git a/Workers/Resgrid.Workers.Console/Commands/AdpMigrationCommand.cs b/Workers/Resgrid.Workers.Console/Commands/AdpMigrationCommand.cs new file mode 100644 index 000000000..032d9ed7d --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/AdpMigrationCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + public sealed class AdpMigrationCommand : IQuidjiboCommand + { + public AdpMigrationCommand(int id) + { + Id = id; + } + + public int Id { get; } + public Guid? CorrelationId { get; set; } + public Dictionary Metadata { get; set; } + } +} diff --git a/Workers/Resgrid.Workers.Console/Program.cs b/Workers/Resgrid.Workers.Console/Program.cs index f8a548381..832701bc8 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -464,6 +464,16 @@ await Client.ScheduleAsync("Chat Export Processor", Cron.MinuteIntervals(5), stoppingToken); + // Frequent on purpose: most sweeps are cheap no-ops (no queued ADP departments, or + // overnight windows closed) and the lock-expiry liveness release should follow a dead + // worker's safety valve promptly. All heavy work runs only inside a department's + // selected overnight window under its operation lock. + _logger.Log(LogLevel.Information, "Scheduling ADP Migration"); + await Client.ScheduleAsync("ADP Migration", + new Commands.AdpMigrationCommand(27), + Cron.MinuteIntervals(5), + stoppingToken); + if (SystemBehaviorConfig.Utf8CleanupEnabled) { var utf8CleanupHour = SystemBehaviorConfig.Utf8CleanupHourUtc >= 0 && SystemBehaviorConfig.Utf8CleanupHourUtc <= 23 diff --git a/Workers/Resgrid.Workers.Console/Tasks/AdpMigrationTask.cs b/Workers/Resgrid.Workers.Console/Tasks/AdpMigrationTask.cs new file mode 100644 index 000000000..fc74f3e40 --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/AdpMigrationTask.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Quidjibo.Handlers; +using Quidjibo.Misc; +using Resgrid.Workers.Console.Commands; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Workers.Console.Tasks +{ + /// + /// ADP migration coordinator sweep: expired-lock liveness, due offboarding flips, and — when a + /// department's overnight window is open — one night of the enrollment/offboarding state machine. + /// Frequent on purpose: most sweeps are cheap no-ops (no queued departments, or windows closed), + /// and the lock-expiry liveness path (plan section 20.4) should not wait long after a dead + /// worker's safety valve passes. + /// + public sealed class AdpMigrationTask : IQuidjiboHandler + { + private readonly ILogger _logger; + + public AdpMigrationTask(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string Name => "ADP Migration"; + public int Priority => 1; + + public async Task ProcessAsync(AdpMigrationCommand command, IQuidjiboProgress progress, CancellationToken cancellationToken) + { + progress?.Report(1, $"Starting the {Name} Task"); + + try + { + var logic = new AdpMigrationLogic(); + var result = await logic.Process(cancellationToken); + + if (!result.Item1) + throw new InvalidOperationException(result.Item2); + + _logger.LogInformation("AdpMigration::{Summary}", result.Item2); + progress?.Report(100, $"Finishing the {Name} Task"); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/AdpMigrationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/AdpMigrationLogic.cs new file mode 100644 index 000000000..87286b45c --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/AdpMigrationLogic.cs @@ -0,0 +1,513 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Workers.Framework.Logic +{ + /// + /// ADP migration coordinator (plan sections 19, 20, 21). Each sweep: releases expired locks and + /// fails their migrations at the cursor; flips due OffboardingScheduled departments to + /// DisableRequested; then picks up to MigrationNightlyConcurrency departments (FIFO) whose + /// department-local overnight window is open and drives one night of the durable state machine — + /// lock, provision, engine run, checkpoint/verify, notify. All bulk data movement lives behind + /// IDepartmentDataMigrationEngine, and nights run only where IDepartmentDataMigrationEngine + /// reports available (a host with a real KMS adapter — the Protected Data Broker). Elsewhere the + /// sweep does liveness/offboarding work and leaves queued departments queued. + /// + /// Deliberately NOT here: gate/addon re-checks. A committed enrollment must never be aborted by + /// flag removal (plan section 3.5), and addon lapses reach the state machine through billing + /// events, not the worker. + /// + public sealed class AdpMigrationLogic + { + private const string WorkerIdentity = "worker:adp-migration"; + + private readonly IDepartmentLockService _lockService; + private readonly IDepartmentDataProtectionPolicyRepository _policyRepository; + private readonly IDepartmentDataProtectionService _protectionService; + private readonly IDepartmentKeyService _keyService; + private readonly IDepartmentDataMigrationEngine _engine; + private readonly IProtectedFieldCatalog _catalog; + private readonly IDepartmentsService _departmentsService; + private readonly IEmailService _emailService; + + public AdpMigrationLogic() + : this( + Bootstrapper.GetKernel().Resolve(), + Bootstrapper.GetKernel().Resolve(), + Bootstrapper.GetKernel().Resolve(), + Bootstrapper.GetKernel().Resolve(), + Bootstrapper.GetKernel().Resolve(), + Bootstrapper.GetKernel().Resolve(), + Bootstrapper.GetKernel().Resolve(), + Bootstrapper.GetKernel().Resolve()) + { + } + + public AdpMigrationLogic(IDepartmentLockService lockService, + IDepartmentDataProtectionPolicyRepository policyRepository, + IDepartmentDataProtectionService protectionService, IDepartmentKeyService keyService, + IDepartmentDataMigrationEngine engine, IProtectedFieldCatalog catalog, + IDepartmentsService departmentsService, IEmailService emailService) + { + _lockService = lockService ?? throw new ArgumentNullException(nameof(lockService)); + _policyRepository = policyRepository ?? throw new ArgumentNullException(nameof(policyRepository)); + _protectionService = protectionService ?? throw new ArgumentNullException(nameof(protectionService)); + _keyService = keyService ?? throw new ArgumentNullException(nameof(keyService)); + _engine = engine ?? throw new ArgumentNullException(nameof(engine)); + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + _departmentsService = departmentsService ?? throw new ArgumentNullException(nameof(departmentsService)); + _emailService = emailService ?? throw new ArgumentNullException(nameof(emailService)); + } + + public async Task> Process(CancellationToken cancellationToken) + { + try + { + var utcNow = DateTime.UtcNow; + var summary = new List(); + + // 1) Liveness: durably release expired locks and fail their migrations at the cursor. + var expired = await _lockService.ReleaseExpiredLocksAsync(cancellationToken); + foreach (var expiredLock in expired.Where(l => l.LockType == (int)DepartmentOperationLockType.AdpMigration)) + { + await FailInFlightMigrationAsync(expiredLock.DepartmentId, "lock_heartbeat_expired", cancellationToken); + await NotifyAdminsAsync(expiredLock.DepartmentId, + "Advanced Data Protection: the overnight migration stopped unexpectedly and your department has returned to full service. Work resumes from its last checkpoint; no action is needed. Support has been alerted."); + summary.Add($"expired lock released for department {expiredLock.DepartmentId}"); + } + + // 2) Offboarding due: end of paid cycle reached — begin the decrypt path. + var policies = (await _policyRepository.GetAllAsync())?.ToList() ?? new List(); + foreach (var policy in policies.Where(p => + p.State == (int)DepartmentDataProtectionState.OffboardingScheduled && + p.OffboardingEffectiveOn.HasValue && p.OffboardingEffectiveOn.Value <= utcNow)) + { + var rows = await _policyRepository.TryTransitionStateAsync(policy.DepartmentId, + DepartmentDataProtectionState.OffboardingScheduled, DepartmentDataProtectionState.DisableRequested, + (int)DepartmentDataProtectionMigrationKind.Offboarding, WorkerIdentity, cancellationToken); + if (rows > 0) + { + policy.State = (int)DepartmentDataProtectionState.DisableRequested; + policy.ActiveMigrationKind = (int)DepartmentDataProtectionMigrationKind.Offboarding; + await _protectionService.InvalidateProtectionCacheAsync(policy.DepartmentId); + summary.Add($"offboarding due for department {policy.DepartmentId}"); + } + } + + // 3) Pick work, unless the operator has paused the queue. + if (DataProtectionConfig.MigrationQueuePaused) + { + summary.Add("queue paused"); + return new Tuple(true, Summarize(summary)); + } + + var workable = policies + .Where(p => IsWorkableState((DepartmentDataProtectionState)p.State)) + .OrderBy(p => p.AcknowledgedOn ?? p.CreatedOn) + .ToList(); + + // Nights run only where the engine can actually move data (a real KMS adapter — the + // Protected Data Broker host). On app/worker hosts the sweep still does the liveness + // and offboarding work above, but queued departments are left QUEUED for the broker + // instead of being marked Failed by a host that can never succeed. + if (workable.Count > 0 && !_engine.IsAvailable) + { + summary.Add($"{workable.Count} department(s) queued; engine unavailable on this host, nights skipped"); + return new Tuple(true, Summarize(summary)); + } + + var concurrency = Math.Max(1, DataProtectionConfig.MigrationNightlyConcurrency); + var executed = 0; + + foreach (var policy in workable) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (executed >= concurrency) + break; + + if (!TryGetOpenWindow(policy, utcNow, out var windowEndUtc)) + continue; + + executed++; + var nightSummary = await ExecuteNightAsync(policy, windowEndUtc, cancellationToken); + summary.Add(nightSummary); + } + + return new Tuple(true, Summarize(summary)); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logging.LogException(ex); + return new Tuple(false, ex.ToString()); + } + } + + private static bool IsWorkableState(DepartmentDataProtectionState state) + { + switch (state) + { + case DepartmentDataProtectionState.EnrollmentQueued: + case DepartmentDataProtectionState.ProvisioningKey: + case DepartmentDataProtectionState.Encrypting: + case DepartmentDataProtectionState.Verifying: + case DepartmentDataProtectionState.DisableRequested: + case DepartmentDataProtectionState.Decrypting: + return true; + + // Failed is deliberately NOT auto-resumed: an operator (or the BackOffice retry + // control) moves it back into the queue after the cause is cleared. + default: + return false; + } + } + + /// + /// True when the department's overnight window is open at . Windows + /// are department-local and may span midnight (the 22:00-06:00 default does). A missing or + /// unresolvable time zone reads as CLOSED — a migration must never run at an unintended local + /// time. + /// + public static bool TryGetOpenWindow(DepartmentDataProtectionPolicy policy, DateTime utcNow, out DateTime windowEndUtc) + { + windowEndUtc = default; + + try + { + if (!TimeSpan.TryParse(string.IsNullOrWhiteSpace(policy.MigrationWindowStartLocal) + ? DataProtectionConfig.MigrationWindowDefaultStartLocal + : policy.MigrationWindowStartLocal, out var start)) + return false; + if (!TimeSpan.TryParse(string.IsNullOrWhiteSpace(policy.MigrationWindowEndLocal) + ? DataProtectionConfig.MigrationWindowDefaultEndLocal + : policy.MigrationWindowEndLocal, out var end)) + return false; + if (string.IsNullOrWhiteSpace(policy.MigrationWindowTimeZone)) + return false; + + var timeZone = TimeZoneInfo.FindSystemTimeZoneById(policy.MigrationWindowTimeZone); + var local = TimeZoneInfo.ConvertTimeFromUtc(utcNow, timeZone); + var time = local.TimeOfDay; + + bool open; + DateTime localEnd; + if (start <= end) + { + open = time >= start && time < end; + localEnd = local.Date.Add(end); + } + else + { + // Window spans midnight (e.g. 22:00 -> 06:00). + open = time >= start || time < end; + localEnd = time >= start ? local.Date.AddDays(1).Add(end) : local.Date.Add(end); + } + + if (!open) + return false; + + var unspecifiedEnd = DateTime.SpecifyKind(localEnd, DateTimeKind.Unspecified); + if (timeZone.IsInvalidTime(unspecifiedEnd)) + { + // Spring-forward gap: the configured window end does not exist as a local time + // today. Treat the window as closed rather than letting ConvertTimeToUtc throw + // and abort the whole sweep (liveness releases included). + Logging.LogError($"ADP migration: department {policy.DepartmentId} window end falls in a DST gap for '{policy.MigrationWindowTimeZone}' today; treating window as closed."); + return false; + } + + windowEndUtc = TimeZoneInfo.ConvertTimeToUtc(unspecifiedEnd, timeZone); + return true; + } + catch (ArgumentException ex) + { + Logging.LogException(ex, $"ADP migration: department {policy.DepartmentId} window end is not a valid local time in '{policy.MigrationWindowTimeZone}'; treating window as closed."); + return false; + } + catch (TimeZoneNotFoundException) + { + Logging.LogError($"ADP migration: department {policy.DepartmentId} has unresolvable window time zone '{policy.MigrationWindowTimeZone}'; treating window as closed."); + return false; + } + catch (InvalidTimeZoneException) + { + Logging.LogError($"ADP migration: department {policy.DepartmentId} has invalid window time zone '{policy.MigrationWindowTimeZone}'; treating window as closed."); + return false; + } + } + + private async Task ExecuteNightAsync(DepartmentDataProtectionPolicy policy, DateTime windowEndUtc, + CancellationToken cancellationToken) + { + var departmentId = policy.DepartmentId; + var correlationId = Guid.NewGuid().ToString("N"); + var kind = policy.ActiveMigrationKind.HasValue + ? (DepartmentDataProtectionMigrationKind)policy.ActiveMigrationKind.Value + : DepartmentDataProtectionMigrationKind.Enrollment; + + var departmentLock = await _lockService.ApplyLockAsync(departmentId, DepartmentOperationLockType.AdpMigration, + "Advanced Data Protection migration in progress — data entry paused", correlationId, WorkerIdentity, + DateTime.UtcNow.AddSeconds(DataProtectionConfig.LockExpirySeconds), windowEndUtc, cancellationToken); + + if (departmentLock == null) + return $"department {departmentId}: lock unavailable, skipped"; + + var releaseKind = DepartmentOperationLockReleaseKind.Checkpoint; + try + { + await NotifyAdminsAsync(departmentId, + "Advanced Data Protection: tonight's migration window has started. Data entry is paused until the window closes; viewing is unaffected."); + + var context = new AdpMigrationNightContext + { + DepartmentId = departmentId, + Kind = kind, + CatalogVersion = _catalog.Version, + WindowEndUtc = windowEndUtc, + DepartmentOperationLockId = departmentLock.DepartmentOperationLockId, + CorrelationId = correlationId, + HeartbeatAsync = () => _lockService.HeartbeatAsync(departmentLock.DepartmentOperationLockId, + DateTime.UtcNow.AddSeconds(DataProtectionConfig.LockExpirySeconds), cancellationToken) + }; + + var state = (DepartmentDataProtectionState)policy.State; + + // --- Enrollment path ----------------------------------------------------------- + if (state == DepartmentDataProtectionState.EnrollmentQueued) + { + if (await _policyRepository.TryTransitionStateAsync(departmentId, DepartmentDataProtectionState.EnrollmentQueued, + DepartmentDataProtectionState.ProvisioningKey, (int)DepartmentDataProtectionMigrationKind.Enrollment, + WorkerIdentity, cancellationToken) == 0) + return $"department {departmentId}: lost enrollment start race, skipped"; + + state = DepartmentDataProtectionState.ProvisioningKey; + } + + if (state == DepartmentDataProtectionState.ProvisioningKey) + { + var key = await _keyService.ProvisionNextKeyVersionAsync(departmentId, cancellationToken); + context.TargetKeyVersion = key.Version; + + if (await _policyRepository.TryTransitionStateAsync(departmentId, DepartmentDataProtectionState.ProvisioningKey, + DepartmentDataProtectionState.Encrypting, (int)DepartmentDataProtectionMigrationKind.Enrollment, + WorkerIdentity, cancellationToken) == 0) + return $"department {departmentId}: provisioning transition race, skipped"; + + await _protectionService.InvalidateProtectionCacheAsync(departmentId); + state = DepartmentDataProtectionState.Encrypting; + } + else if (kind != DepartmentDataProtectionMigrationKind.Offboarding) + { + var activeKey = await _keyService.GetActiveKeyAsync(departmentId); + context.TargetKeyVersion = activeKey?.Version; + } + + if (state == DepartmentDataProtectionState.Encrypting) + { + var night = await _engine.RunEncryptionNightAsync(context, cancellationToken); + if (night.Outcome == AdpMigrationNightOutcome.WindowClosed) + { + await NotifyAdminsAsync(departmentId, + $"Advanced Data Protection: tonight's migration checkpoint is complete ({night.PercentComplete?.ToString() ?? "?"}% done). Your department is back in full service; work resumes the next scheduled night."); + return $"department {departmentId}: encryption checkpointed"; + } + + if (night.Outcome == AdpMigrationNightOutcome.Failed) + { + releaseKind = DepartmentOperationLockReleaseKind.Aborted; + await FailInFlightMigrationAsync(departmentId, night.ErrorCode, cancellationToken); + await NotifyFailureAsync(departmentId); + return $"department {departmentId}: encryption failed ({night.ErrorCode})"; + } + + if (await _policyRepository.TryTransitionStateAsync(departmentId, DepartmentDataProtectionState.Encrypting, + DepartmentDataProtectionState.Verifying, (int)DepartmentDataProtectionMigrationKind.Enrollment, + WorkerIdentity, cancellationToken) == 0) + return $"department {departmentId}: verify transition race"; + + state = DepartmentDataProtectionState.Verifying; + } + + // --- Offboarding path ---------------------------------------------------------- + if (state == DepartmentDataProtectionState.DisableRequested) + { + if (await _policyRepository.TryTransitionStateAsync(departmentId, DepartmentDataProtectionState.DisableRequested, + DepartmentDataProtectionState.Decrypting, (int)DepartmentDataProtectionMigrationKind.Offboarding, + WorkerIdentity, cancellationToken) == 0) + return $"department {departmentId}: offboarding start race, skipped"; + + await NotifyAdminsAsync(departmentId, + "Advanced Data Protection: offboarding has started. Protection remains in effect until your data is fully restored to standard storage."); + await _protectionService.InvalidateProtectionCacheAsync(departmentId); + state = DepartmentDataProtectionState.Decrypting; + } + + if (state == DepartmentDataProtectionState.Decrypting) + { + var night = await _engine.RunDecryptionNightAsync(context, cancellationToken); + if (night.Outcome == AdpMigrationNightOutcome.WindowClosed) + { + await NotifyAdminsAsync(departmentId, + $"Advanced Data Protection: tonight's offboarding checkpoint is complete ({night.PercentComplete?.ToString() ?? "?"}% done). Your department is back in full service; work resumes the next scheduled night."); + return $"department {departmentId}: decryption checkpointed"; + } + + if (night.Outcome == AdpMigrationNightOutcome.Failed) + { + releaseKind = DepartmentOperationLockReleaseKind.Aborted; + await FailInFlightMigrationAsync(departmentId, night.ErrorCode, cancellationToken); + await NotifyFailureAsync(departmentId); + return $"department {departmentId}: decryption failed ({night.ErrorCode})"; + } + + if (await _policyRepository.TryTransitionStateAsync(departmentId, DepartmentDataProtectionState.Decrypting, + DepartmentDataProtectionState.Verifying, (int)DepartmentDataProtectionMigrationKind.Offboarding, + WorkerIdentity, cancellationToken) == 0) + return $"department {departmentId}: verify transition race"; + + state = DepartmentDataProtectionState.Verifying; + } + + // --- Verification (shared; direction from the migration kind) ------------------ + if (state == DepartmentDataProtectionState.Verifying) + { + var verified = await _engine.VerifyAsync(context, cancellationToken); + if (!verified) + { + releaseKind = DepartmentOperationLockReleaseKind.Aborted; + await FailInFlightMigrationAsync(departmentId, "verification_failed", cancellationToken); + await NotifyFailureAsync(departmentId); + return $"department {departmentId}: verification failed"; + } + + if (kind == DepartmentDataProtectionMigrationKind.Offboarding) + { + if (await _policyRepository.TryTransitionStateAsync(departmentId, DepartmentDataProtectionState.Verifying, + DepartmentDataProtectionState.Disabled, null, WorkerIdentity, cancellationToken) == 0) + return $"department {departmentId}: disable transition race"; + + await _protectionService.IncrementPolicyEpochAsync(departmentId, WorkerIdentity, cancellationToken); + await NotifyAdminsAsync(departmentId, + "Advanced Data Protection: offboarding is complete. Your department has returned to standard storage. Re-enabling requires purchasing the addon again and completing a new enrollment."); + releaseKind = DepartmentOperationLockReleaseKind.Completed; + return $"department {departmentId}: offboarding complete"; + } + + if (await _policyRepository.TryTransitionStateAsync(departmentId, DepartmentDataProtectionState.Verifying, + DepartmentDataProtectionState.Enabled, null, WorkerIdentity, cancellationToken) == 0) + return $"department {departmentId}: enable transition race"; + + var enabledPolicy = await _policyRepository.GetByDepartmentIdAsync(departmentId); + if (enabledPolicy != null) + { + enabledPolicy.CatalogVersion = context.CatalogVersion; + enabledPolicy.UpdatedOn = DateTime.UtcNow; + enabledPolicy.UpdatedByUserId = WorkerIdentity; + await _policyRepository.SaveOrUpdateAsync(enabledPolicy, cancellationToken); + } + + await _protectionService.IncrementPolicyEpochAsync(departmentId, WorkerIdentity, cancellationToken); + await NotifyAdminsAsync(departmentId, + "Advanced Data Protection: verification passed and protection is now ACTIVE for your department."); + releaseKind = DepartmentOperationLockReleaseKind.Completed; + return $"department {departmentId}: enrollment complete, protection active"; + } + + return $"department {departmentId}: no work for state {(DepartmentDataProtectionState)policy.State}"; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Worker shutdown/redeploy is NOT a migration failure: leave the durable state + // untouched (the next sweep resumes from the cursor) and release the lock as a + // checkpoint, mirroring how Process propagates cancellation. + throw; + } + catch (Exception ex) + { + releaseKind = DepartmentOperationLockReleaseKind.Aborted; + Logging.LogException(ex, $"ADP migration night failed for department {departmentId}"); + await FailInFlightMigrationAsync(departmentId, "night_execution_error", cancellationToken); + await NotifyFailureAsync(departmentId); + return $"department {departmentId}: night execution error"; + } + finally + { + await _lockService.ReleaseLockAsync(departmentLock.DepartmentOperationLockId, releaseKind, WorkerIdentity, CancellationToken.None); + } + } + + /// Moves an in-flight (transitional) state to Failed, preserving the migration kind for resume. + private async Task FailInFlightMigrationAsync(int departmentId, string errorCode, CancellationToken cancellationToken) + { + var policy = await _policyRepository.GetByDepartmentIdAsync(departmentId); + if (policy == null) + return; + + var state = (DepartmentDataProtectionState)policy.State; + if (state != DepartmentDataProtectionState.ProvisioningKey && + state != DepartmentDataProtectionState.Encrypting && + state != DepartmentDataProtectionState.Verifying && + state != DepartmentDataProtectionState.Decrypting && + state != DepartmentDataProtectionState.Rotating) + return; + + await _policyRepository.TryTransitionStateAsync(departmentId, state, DepartmentDataProtectionState.Failed, + policy.ActiveMigrationKind, WorkerIdentity, cancellationToken); + await _protectionService.InvalidateProtectionCacheAsync(departmentId); + Logging.LogError($"ADP migration for department {departmentId} marked Failed ({errorCode}); resumable from its cursor."); + } + + private async Task NotifyFailureAsync(int departmentId) + { + await NotifyAdminsAsync(departmentId, + "Advanced Data Protection: tonight's migration run could not complete and will resume after review. Your department is in full service and your data remains safe. Support has been alerted."); + } + + /// + /// Emails every department admin (managing member included) with value-free content only — + /// counts and states, never protected values (plan section 19.5). Notification failure never + /// fails the migration. + /// + private async Task NotifyAdminsAsync(int departmentId, string message) + { + try + { + var admins = await _departmentsService.GetAllAdminsForDepartmentAsync(departmentId); + if (admins == null) + return; + + foreach (var admin in admins) + { + try + { + await _emailService.SendNotificationAsync(admin.UserId, message, departmentId); + } + catch (Exception ex) + { + Logging.LogException(ex, $"ADP migration notification to user {admin.UserId} failed for department {departmentId}"); + } + } + } + catch (Exception ex) + { + Logging.LogException(ex, $"ADP migration notification fan-out failed for department {departmentId}"); + } + } + + private static string Summarize(List parts) => + parts.Count == 0 ? "ADP migration sweep: no work" : "ADP migration sweep: " + string.Join("; ", parts); + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/CallEmailImporterLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/CallEmailImporterLogic.cs index e4108fd43..d7d0f6ef5 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/CallEmailImporterLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/CallEmailImporterLogic.cs @@ -31,6 +31,13 @@ public async Task> Process(CallEmailQueueItem item) if (!String.IsNullOrWhiteSpace(item?.EmailSettings?.Hostname)) { + // ADP department operation lock: email-call ingestion is deferred while the department + // is locked (plan section 20.2). The check MUST run before touching the mail server — + // the POP fetch removes messages from the mailbox, so deferring here leaves them + // queued server-side to be ingested after the lock releases. + if (await DepartmentLockGuard.IsDepartmentLockedAsync(item.EmailSettings.DepartmentId)) + return new Tuple(true, $"deferred: department {item.EmailSettings.DepartmentId} is locked"); + CallEmailsResult emailResult = _callEmailProvider.GetAllCallEmailsFromServer(item.EmailSettings); if (emailResult?.Emails != null && emailResult.Emails.Count > 0) diff --git a/Workers/Resgrid.Workers.Framework/Logic/ChatbotMessageLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ChatbotMessageLogic.cs index 4a23fe06e..4bf4fd0f5 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ChatbotMessageLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ChatbotMessageLogic.cs @@ -17,6 +17,25 @@ namespace Resgrid.Workers.Framework.Logic /// public class ChatbotMessageLogic { + /// + /// True when the message is the SMS opt-out command. Uses the same TextCommandTypes.Stop + /// classification as the webhook so every STOP variant it honors is honored here; a resolver + /// fault falls back to a literal "STOP" compare rather than blocking an opt-out. + /// + private static bool IsStopCommand(string body) + { + try + { + var textCommandService = Bootstrapper.GetKernel().Resolve(); + return textCommandService.DetermineType(body).Type == Model.TextCommandTypes.Stop; + } + catch (Exception ex) + { + Logging.LogException(ex, "Chatbot STOP classification failed; falling back to literal compare."); + return string.Equals(body?.Trim(), "STOP", StringComparison.OrdinalIgnoreCase); + } + } + public static async Task ProcessChatbotMessageQueueItem(ChatbotMessageQueueItem item) { if (item == null || string.IsNullOrWhiteSpace(item.From) || string.IsNullOrWhiteSpace(item.Body)) @@ -45,24 +64,7 @@ public static async Task ProcessChatbotMessageQueueItem(ChatbotMessageQueu } } - var message = new ChatbotMessage - { - MessageId = item.MessageId, - From = item.From, - To = item.To, - Text = item.Body, - Platform = (ChatbotPlatform)item.Platform, - Timestamp = DateTime.UtcNow - }; - - // Command-board questions carry the incident the sender has open so "PAR" means "PAR on - // this board". The ingress copies it onto the session; authorization is re-checked there. - if (item.IncidentCallId.HasValue && item.IncidentCallId.Value > 0) - message.PlatformMetadata["incidentCallId"] = item.IncidentCallId.Value; - - var response = await chatbotIngressService.ProcessMessageAsync(message); - - if (response != null && !string.IsNullOrWhiteSpace(response.Text)) + async Task SendReplyAsync(string text) { if ((ChatbotPlatform)item.Platform == ChatbotPlatform.WebChat) { @@ -72,7 +74,7 @@ public static async Task ProcessChatbotMessageQueueItem(ChatbotMessageQueu // in the department the message actually came from. var notifier = Bootstrapper.GetKernel().Resolve(); if (notifier != null) - await notifier.PushToUserAsync(item.From, response.Text, item.DepartmentId); + await notifier.PushToUserAsync(item.From, text, item.DepartmentId); } else { @@ -80,7 +82,7 @@ public static async Task ProcessChatbotMessageQueueItem(ChatbotMessageQueu // primary transport; carrier only governs gateway fallback, so the default is fine here. // Chatbot replies are interactive (help/command lists the user acts on over SMS), so they // use the higher chatbot length cap instead of the notification default. - await textMessageProvider.SendTextMessage(item.From, response.Text, item.To, default(MobileCarriers), item.DepartmentId, + await textMessageProvider.SendTextMessage(item.From, text, item.To, default(MobileCarriers), item.DepartmentId, maxLengthOverride: Resgrid.Config.ChatbotConfig.SmsReplyMaxLength); } @@ -96,6 +98,39 @@ public static async Task ProcessChatbotMessageQueueItem(ChatbotMessageQueu } } } + + // ADP department operation lock (plan section 20.2): while a migration window is open, + // the chatbot pipeline must not run — its intents mutate cataloged department data + // (create call, respond, close) below the API lock filter. Unlike other consumers this + // one answers NOW with the paused banner instead of deferring: an SMS command replayed + // half an hour later would act on stale intent. Value-free text; fail-open guard. + // EXCEPTION: STOP always works — opting out of messages is not a department-data + // mutation and must never be blocked by a migration lock. + if (await DepartmentLockGuard.IsDepartmentLockedAsync(item.DepartmentId) && !IsStopCommand(item.Body)) + { + await SendReplyAsync("Resgrid is briefly paused for scheduled maintenance in your department. Please try again shortly."); + return true; + } + + var message = new ChatbotMessage + { + MessageId = item.MessageId, + From = item.From, + To = item.To, + Text = item.Body, + Platform = (ChatbotPlatform)item.Platform, + Timestamp = DateTime.UtcNow + }; + + // Command-board questions carry the incident the sender has open so "PAR" means "PAR on + // this board". The ingress copies it onto the session; authorization is re-checked there. + if (item.IncidentCallId.HasValue && item.IncidentCallId.Value > 0) + message.PlatformMetadata["incidentCallId"] = item.IncidentCallId.Value; + + var response = await chatbotIngressService.ProcessMessageAsync(message); + + if (response != null && !string.IsNullOrWhiteSpace(response.Text)) + await SendReplyAsync(response.Text); } catch (Exception ex) { diff --git a/Workers/Resgrid.Workers.Framework/Logic/DepartmentLockGuard.cs b/Workers/Resgrid.Workers.Framework/Logic/DepartmentLockGuard.cs new file mode 100644 index 000000000..af9714e80 --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/DepartmentLockGuard.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Framework; +using Resgrid.Model.Services; + +namespace Resgrid.Workers.Framework.Logic +{ + /// + /// Shared worker-side enforcement of the department operation lock (ADP plan section 20.2): + /// queue consumers and scheduled-task executors call this before performing a department-scoped + /// mutation and DEFER the item when locked — skip without completing so the scheduler re-picks + /// it, or requeue, but never dead-letter and never process. Reads are unaffected. + /// + /// Failure posture matches IDepartmentLockService: a lock-store fault reads as unlocked (fail + /// open) — the lock protects a migration, dispatch availability beats migration progress, and + /// the migration worker refuses to proceed when it cannot verify its own lock. + /// + public static class DepartmentLockGuard + { + public static async Task IsDepartmentLockedAsync(int departmentId) + { + if (departmentId <= 0) + return false; + + try + { + var lockService = Bootstrapper.GetKernel().Resolve(); + return await lockService.IsDepartmentLockedAsync(departmentId); + } + catch (Exception ex) + { + Logging.LogException(ex, $"DepartmentLockGuard failed for department {departmentId}; failing open (unlocked)"); + return false; + } + } + } +} diff --git a/Workers/Resgrid.Workers.Framework/Logic/StaffingScheduleLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/StaffingScheduleLogic.cs index 700aa66c4..2f94c1938 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/StaffingScheduleLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/StaffingScheduleLogic.cs @@ -29,6 +29,12 @@ public async Task> Process(StaffingScheduleQueueItem item) if (item != null && item.ScheduledTask != null) { + // ADP department operation lock: staffing mutations are deferred, not dropped — the + // occurrence is skipped WITHOUT a completion log so the scheduler re-picks it after + // the lock releases (plan section 20.2). + if (await DepartmentLockGuard.IsDepartmentLockedAsync(item.ScheduledTask.DepartmentId)) + return new Tuple(true, $"deferred: department {item.ScheduledTask.DepartmentId} is locked"); + try { if (item.ScheduledTask.TaskType == (int)TaskTypes.UserStaffingLevel) diff --git a/Workers/Resgrid.Workers.Framework/Logic/StatusScheduleLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/StatusScheduleLogic.cs index 2a349219f..509a22d83 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/StatusScheduleLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/StatusScheduleLogic.cs @@ -29,6 +29,12 @@ public async Task> Process(StatusScheduleQueueItem item) if (item != null && item.ScheduledTask != null) { + // ADP department operation lock: status mutations are deferred, not dropped — the + // occurrence is skipped WITHOUT a completion log so the scheduler re-picks it after + // the lock releases (plan section 20.2). + if (await DepartmentLockGuard.IsDepartmentLockedAsync(item.ScheduledTask.DepartmentId)) + return new Tuple(true, $"deferred: department {item.ScheduledTask.DepartmentId} is locked"); + try { if (item.ScheduledTask.TaskType == (int)TaskTypes.DepartmentStatusReset) diff --git a/Workers/Resgrid.Workers.Framework/Logic/WorkflowQueueLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/WorkflowQueueLogic.cs index a1fd79110..beb61167e 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/WorkflowQueueLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/WorkflowQueueLogic.cs @@ -18,6 +18,19 @@ public static async Task ProcessWorkflowQueueItem(WorkflowQueueItem item, try { + // ADP department operation lock: workflow executions can mutate department data, so a + // locked department's items are requeued unchanged (same attempt number — deferral is + // not a retry) rather than executed or dead-lettered (plan section 20.2). The short + // pause keeps the small pre-lock backlog from hot-cycling the bus for the whole + // window; new triggers barely arrive while the lock blocks mutations upstream. + if (await DepartmentLockGuard.IsDepartmentLockedAsync(item.DepartmentId)) + { + await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken); + var deferralQueue = Bootstrapper.GetKernel().Resolve(); + await deferralQueue.EnqueueWorkflow(item); + return true; + } + var workflowService = Bootstrapper.GetKernel().Resolve(); var departmentsService = Bootstrapper.GetKernel().Resolve();