From 89c35b3e51133e5d3237a945e8d937947360b3c9 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 29 Aug 2026 11:56:08 -0700 Subject: [PATCH 1/4] RG-T89 ADP Work Next round --- AGENTS.md | 25 + CLAUDE.md | 111 ++-- Core/Resgrid.Config/DataProtectionConfig.cs | 15 + Core/Resgrid.Config/UrlsConfig.cs | 2 +- .../DataProtection/DataProtection.ar.resx | 391 ++++++++++++ .../User/DataProtection/DataProtection.cs | 11 + .../DataProtection/DataProtection.de.resx | 391 ++++++++++++ .../DataProtection/DataProtection.el.resx | 391 ++++++++++++ .../DataProtection/DataProtection.en.resx | 391 ++++++++++++ .../DataProtection/DataProtection.es.resx | 391 ++++++++++++ .../DataProtection/DataProtection.fr.resx | 391 ++++++++++++ .../DataProtection/DataProtection.it.resx | 391 ++++++++++++ .../DataProtection/DataProtection.pl.resx | 391 ++++++++++++ .../DataProtection/DataProtection.sv.resx | 391 ++++++++++++ .../DataProtection/DataProtection.uk.resx | 391 ++++++++++++ .../SystemMessages/SystemMessages.ar.resx | 216 ++++--- .../SystemMessages/SystemMessages.de.resx | 216 ++++--- .../SystemMessages/SystemMessages.el.resx | 216 ++++--- .../SystemMessages/SystemMessages.en.resx | 216 ++++--- .../SystemMessages/SystemMessages.es.resx | 216 ++++--- .../SystemMessages/SystemMessages.fr.resx | 216 ++++--- .../SystemMessages/SystemMessages.it.resx | 216 ++++--- .../SystemMessages/SystemMessages.pl.resx | 216 ++++--- .../SystemMessages/SystemMessages.sv.resx | 216 ++++--- .../SystemMessages/SystemMessages.uk.resx | 216 ++++--- Core/Resgrid.Localization/Common.ar.resx | 60 ++ Core/Resgrid.Localization/Common.de.resx | 60 ++ Core/Resgrid.Localization/Common.el.resx | 60 ++ Core/Resgrid.Localization/Common.en.resx | 60 ++ Core/Resgrid.Localization/Common.es.resx | 60 ++ Core/Resgrid.Localization/Common.fr.resx | 60 ++ Core/Resgrid.Localization/Common.it.resx | 60 ++ Core/Resgrid.Localization/Common.pl.resx | 60 ++ Core/Resgrid.Localization/Common.sv.resx | 60 ++ Core/Resgrid.Localization/Common.uk.resx | 60 ++ .../Resgrid.Model/AdpMigrationNightContext.cs | 7 + Core/Resgrid.Model/Call.cs | 7 + .../DepartmentDataProtectionMigrationKind.cs | 10 +- .../DepartmentMemberEmergencyContact.cs | 69 ++ .../DepartmentMemberSensitiveData.cs | 42 +- .../MemberProfileRelocationResult.cs | 36 ++ Core/Resgrid.Model/PersonnelCertification.cs | 7 + Core/Resgrid.Model/ProtectedEgressScanner.cs | 300 +++++++++ Core/Resgrid.Model/ProtectedOutboundGuard.cs | 59 ++ .../Providers/IProtectedDataBrokerClient.cs | 7 +- ...artmentMemberEmergencyContactRepository.cs | 18 + ...DepartmentMemberSensitiveDataRepository.cs | 12 + .../IDepartmentDataProtectionService.cs | 15 + ...DepartmentMemberEmergencyContactService.cs | 37 ++ .../IDepartmentMemberSensitiveDataService.cs | 53 ++ .../IMemberProfileRelocationService.cs | 38 ++ .../Services/IProtectedFieldCatalog.cs | 21 +- .../Services/IProtectedFieldCryptoService.cs | 20 +- .../Services/IProtectedReadService.cs | 46 ++ .../Services/IProtectedWriteService.cs | 38 ++ Core/Resgrid.Services/AdpTableBindings.cs | 103 ++- Core/Resgrid.Services/CallsService.cs | 114 +++- Core/Resgrid.Services/CertificationService.cs | 33 +- Core/Resgrid.Services/CheckInTimerService.cs | 4 +- Core/Resgrid.Services/DeleteService.cs | 15 +- .../DepartmentDataMigrationEngine.cs | 41 +- .../DepartmentDataProtectionService.cs | 40 +- ...DepartmentMemberEmergencyContactService.cs | 117 ++++ .../DepartmentMemberSensitiveDataService.cs | 112 ++++ .../Resgrid.Services/GdprDataExportService.cs | 227 ++++++- .../IncidentCommandService.cs | 2 +- .../MemberProfileRelocationService.cs | 158 +++++ .../Resgrid.Services/ProtectedFieldCatalog.cs | 147 ++++- .../ProtectedFieldCryptoService.cs | 32 +- .../ProtectedProjectionService.cs | 25 +- .../ProtectedPushServiceDecorator.cs | 125 ++++ Core/Resgrid.Services/ProtectedReadService.cs | 599 +++++++++++++++++- Core/Resgrid.Services/ServicesModule.cs | 6 + Core/Resgrid.Services/UdfRenderingService.cs | 6 +- Core/Resgrid.Services/UnitsService.cs | 31 +- Core/Resgrid.Services/WorkLogsService.cs | 32 +- .../WorkflowTemplateContextBuilder.cs | 83 ++- .../EmailProviderModule.cs | 3 + .../ProtectedEmailSenderDecorator.cs | 154 +++++ ...31_AddDepartmentMemberEmergencyContacts.cs | 69 ++ ...132_BackfillMemberIdentificationNumbers.cs | 43 ++ .../M0133_AddMemberDepartmentAddresses.cs | 69 ++ .../M0134_CompleteMemberProfileRelocation.cs | 96 +++ ...0135_PrepareCertificationsForProtection.cs | 43 ++ ..._AddDepartmentMemberEmergencyContactsPg.cs | 67 ++ ...2_BackfillMemberIdentificationNumbersPg.cs | 34 + .../M0133_AddMemberDepartmentAddressesPg.cs | 58 ++ ...M0134_CompleteMemberProfileRelocationPg.cs | 74 +++ ...35_PrepareCertificationsForProtectionPg.cs | 26 + .../NumbersProviderModule.cs | 5 + ...ProtectedOutboundVoiceProviderDecorator.cs | 89 +++ .../ProtectedTextMessageProviderDecorator.cs | 56 ++ ...artmentMemberEmergencyContactRepository.cs | 64 ++ ...DepartmentMemberSensitiveDataRepository.cs | 37 ++ .../Modules/DataModule.cs | 1 + Tests/Resgrid.Tests/Bootstrapper.cs | 10 + .../TranslationCompletenessTests.cs | 11 + Tests/Resgrid.Tests/Models/CallTests.cs | 25 + .../Services/AdpCatalogUpgradeScopeTests.cs | 88 +++ .../Services/AdpSizingServiceTests.cs | 8 +- .../CallsServiceProtectedWriteTests.cs | 67 ++ .../DepartmentDataMigrationEngineTests.cs | 8 +- .../DepartmentDataProtectionServiceTests.cs | 49 +- .../DocumentDatabaseProviderSelectionTests.cs | 19 +- .../Services/GdprExportProtectedDataTests.cs | 261 ++++++++ .../Services/MemberDataDeletionTests.cs | 99 +++ .../MemberEmergencyContactProtectionTests.cs | 183 ++++++ .../MemberIdentificationNumberScopeTests.cs | 118 ++++ .../Services/MemberProfileRelocationTests.cs | 269 ++++++++ .../Services/ProtectedDataEnvelopeTests.cs | 27 + .../Services/ProtectedEgressScannerTests.cs | 183 ++++++ .../Services/ProtectedFieldCatalogTests.cs | 68 +- .../ProtectedFieldCryptoServiceTests.cs | 61 +- .../Services/ProtectedOutboundGuardTests.cs | 185 ++++++ .../ProtectedProjectionServiceTests.cs | 45 ++ .../Services/ProtectedReadServiceTests.cs | 493 +++++++++++++- .../UnitsServiceProtectedWriteTests.cs | 109 ++++ .../WorkflowTemplateContextBuilderTests.cs | 14 +- .../WorkflowTemplateRedactionTests.cs | 44 ++ .../User/CertificationAuthorizationTests.cs | 111 ++++ .../Workers/AdpMigrationLogicTests.cs | 110 +++- .../MemberProfileRelocationLogicTests.cs | 120 ++++ .../Services/AdpMigrationSweepService.cs | 3 +- .../Services/BrokerOperationService.cs | 8 +- .../Controllers/v4/CallFilesController.cs | 2 +- .../Controllers/v4/CallNotesController.cs | 2 +- .../Controllers/v4/MappingController.cs | 58 +- .../Controllers/v4/PersonnelController.cs | 36 ++ .../Controllers/v4/RoutesController.cs | 22 +- .../Controllers/v4/UnitsController.cs | 33 +- .../v4/UserDefinedFieldsController.cs | 11 +- .../Filters/ProtectedDataEgressFilter.cs | 107 ++++ .../GetTestRunReportResult.cs | 2 +- .../Resgrid.Web.Services.xml | 45 +- Web/Resgrid.Web.Services/Startup.cs | 4 + .../User/Controllers/DispatchController.cs | 9 + .../Areas/User/Controllers/HomeController.cs | 375 +++++++++-- .../Areas/User/Controllers/LinksController.cs | 7 +- .../Areas/User/Controllers/LogsController.cs | 30 +- .../User/Controllers/MappingController.cs | 65 +- .../User/Controllers/PersonnelController.cs | 2 +- .../User/Controllers/ProfileController.cs | 132 +++- .../User/Controllers/ReportsController.cs | 127 ++-- .../Areas/User/Controllers/UnitsController.cs | 4 +- .../User/Models/Profile/CertificationsView.cs | 6 + .../Areas/User/Views/Contacts/View.cshtml | 29 +- .../User/Views/DataProtection/Index.cshtml | 188 +++--- .../Areas/User/Views/Dispatch/ViewCall.cshtml | 28 +- .../User/Views/Home/EditUserProfile.cshtml | 96 +++ .../User/Views/Profile/Certifications.cshtml | 93 ++- .../Filters/ProtectedDataEgressFilter.cs | 137 ++++ Web/Resgrid.Web/Startup.cs | 4 + .../dataprotection/resgrid.adp.reveal.js | 76 ++- .../resgrid.dataprotection.wizard.js | 22 +- .../profile/resgrid.emergencycontacts.js | 151 +++++ .../MemberProfileRelocationCommand.cs | 18 + Workers/Resgrid.Workers.Console/Program.cs | 8 + .../Tasks/MemberProfileRelocationTask.cs | 52 ++ .../Logic/AdpMigrationLogic.cs | 57 +- .../Logic/MemberProfileRelocationLogic.cs | 110 ++++ 160 files changed, 14181 insertions(+), 1479 deletions(-) create mode 100644 Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.ar.resx create mode 100644 Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.cs create mode 100644 Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.de.resx create mode 100644 Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.el.resx create mode 100644 Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.en.resx create mode 100644 Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.es.resx create mode 100644 Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.fr.resx create mode 100644 Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.it.resx create mode 100644 Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.pl.resx create mode 100644 Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.sv.resx create mode 100644 Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.uk.resx create mode 100644 Core/Resgrid.Model/DepartmentMemberEmergencyContact.cs create mode 100644 Core/Resgrid.Model/MemberProfileRelocationResult.cs create mode 100644 Core/Resgrid.Model/ProtectedEgressScanner.cs create mode 100644 Core/Resgrid.Model/ProtectedOutboundGuard.cs create mode 100644 Core/Resgrid.Model/Repositories/IDepartmentMemberEmergencyContactRepository.cs create mode 100644 Core/Resgrid.Model/Services/IDepartmentMemberEmergencyContactService.cs create mode 100644 Core/Resgrid.Model/Services/IDepartmentMemberSensitiveDataService.cs create mode 100644 Core/Resgrid.Model/Services/IMemberProfileRelocationService.cs create mode 100644 Core/Resgrid.Services/DepartmentMemberEmergencyContactService.cs create mode 100644 Core/Resgrid.Services/DepartmentMemberSensitiveDataService.cs create mode 100644 Core/Resgrid.Services/MemberProfileRelocationService.cs create mode 100644 Core/Resgrid.Services/ProtectedPushServiceDecorator.cs create mode 100644 Providers/Resgrid.Providers.Email/ProtectedEmailSenderDecorator.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0131_AddDepartmentMemberEmergencyContacts.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0132_BackfillMemberIdentificationNumbers.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0133_AddMemberDepartmentAddresses.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0134_CompleteMemberProfileRelocation.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0135_PrepareCertificationsForProtection.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0131_AddDepartmentMemberEmergencyContactsPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0132_BackfillMemberIdentificationNumbersPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0133_AddMemberDepartmentAddressesPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0134_CompleteMemberProfileRelocationPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0135_PrepareCertificationsForProtectionPg.cs create mode 100644 Providers/Resgrid.Providers.Number/ProtectedOutboundVoiceProviderDecorator.cs create mode 100644 Providers/Resgrid.Providers.Number/ProtectedTextMessageProviderDecorator.cs create mode 100644 Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberEmergencyContactRepository.cs create mode 100644 Tests/Resgrid.Tests/Services/AdpCatalogUpgradeScopeTests.cs create mode 100644 Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs create mode 100644 Tests/Resgrid.Tests/Services/MemberDataDeletionTests.cs create mode 100644 Tests/Resgrid.Tests/Services/MemberEmergencyContactProtectionTests.cs create mode 100644 Tests/Resgrid.Tests/Services/MemberIdentificationNumberScopeTests.cs create mode 100644 Tests/Resgrid.Tests/Services/MemberProfileRelocationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ProtectedEgressScannerTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ProtectedOutboundGuardTests.cs create mode 100644 Tests/Resgrid.Tests/Services/UnitsServiceProtectedWriteTests.cs create mode 100644 Tests/Resgrid.Tests/Services/WorkflowTemplateRedactionTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/CertificationAuthorizationTests.cs create mode 100644 Tests/Resgrid.Tests/Workers/MemberProfileRelocationLogicTests.cs create mode 100644 Web/Resgrid.Web.Services/Filters/ProtectedDataEgressFilter.cs create mode 100644 Web/Resgrid.Web/Filters/ProtectedDataEgressFilter.cs create mode 100644 Web/Resgrid.Web/wwwroot/js/app/internal/profile/resgrid.emergencycontacts.js create mode 100644 Workers/Resgrid.Workers.Console/Commands/MemberProfileRelocationCommand.cs create mode 100644 Workers/Resgrid.Workers.Console/Tasks/MemberProfileRelocationTask.cs create mode 100644 Workers/Resgrid.Workers.Framework/Logic/MemberProfileRelocationLogic.cs diff --git a/AGENTS.md b/AGENTS.md index 5caec049b..bb4fdee58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -226,6 +226,31 @@ Plan limits are cached for **14 days** (`TimeSpan.FromDays(14)`). Most user/depa | Worker logic | `Workers/Resgrid.Workers.Framework/Logic/` | | Worker queue items | `Core/Resgrid.Model/Queue/` | +## Source Control + +**Agents never run `git commit` and never run `git push`. There is no exception, including +being asked to.** + +The commit is a human verification gate in front of the PR process. Its value comes from a +person having read the change and chosen to record it — an agent commit destroys that, and the +gate cannot be reconstructed afterwards. A request to commit is also not reliable evidence of +intent: it may be a typo, an accidental autocompletion, or a stale line in a longer message. +Because the gate exists precisely to catch what nobody meant to do, the request itself is not +sufficient authorization, no matter how it is phrased or how many times it is repeated. + +- **Never `git commit`.** Not when asked, not when a task is finished, not when the build is + green, not "so CI can run". If asked, decline, say why, and hand over the command. +- **Never `git push`.** Same rule, same reasoning, and worse consequences: once it is on the + remote, CI has run and reviewers may have seen it. +- **Never rewrite or delete published history** — no `push --force`, no `--force-with-lease`, + no remote branch deletion. +- **Never stage-and-commit indirectly either** — no `git commit -am`, no `git revert`, no + `git cherry-pick`, no amend, no `gh pr create`, no alias or script that ends in a commit. +- Leaving work uncommitted **is** the finished state. Report what changed, why, and the commit + message you would suggest. The user reads the diff and commits it themselves. + +Changing this rule is a deliberate edit to this file, not something granted in conversation. + ## Common Tasks **Build the entire solution:** diff --git a/CLAUDE.md b/CLAUDE.md index 97f2c1e22..737904ed5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,50 +1,3 @@ - -# Dual-Graph Context Policy - -This project uses a local dual-graph MCP server (graperoot-pro) for efficient, -budget-aware context retrieval. Always prefer it over native file exploration. - -## MANDATORY: Always follow this order - -1. **Call `graph_continue` first** -- before any file exploration, grep, or code reading. - -2. **If `graph_continue` returns `needs_project=true`**: call `graph_scan` with the - current project directory (`pwd`). Do NOT ask the user. - -3. **If `graph_continue` returns `skip=true`**: project is too small for the graph to - help. Skip all graph tools and explore normally. - -4. **Read `recommended_files`** using `graph_read` -- one call per file. - - `recommended_files` may contain `file::symbol` entries (e.g. `src/auth.ts::handleLogin`). - Pass them verbatim to `graph_read(file: "src/auth.ts::handleLogin")` -- it reads only - that symbol's lines, not the full file. - -5. **Check `confidence` and obey the caps strictly:** - - `confidence=high` -> Stop. Do NOT grep or explore further. - - `confidence=medium` -> If recommended files are insufficient, call `fallback_rg` - at most `max_supplementary_greps` time(s) with specific terms, then `graph_read` - at most `max_supplementary_files` additional file(s). Then stop. - - `confidence=low` -> Call `fallback_rg` at most `max_supplementary_greps` time(s), - then `graph_read` at most `max_supplementary_files` file(s). Then stop. - -## Exhaustive enumeration tasks - -Some tasks require scanning **every file** -- e.g. "find all dead exports", "list every -.find() without a limit", "audit all test files". Use these tools first: - -- **`graph_dead_exports()`** -- pre-computed at scan time. Use for any dead-export task. -- **`graph_grep_all(pattern, file_glob?, max_hits?)`** -- exhaustive grep, no call cap. - -## Rules - -- Do NOT use `rg`, `grep`, or bash file exploration before calling `graph_continue`. -- Do NOT do broad/recursive exploration at any confidence level. -- After edits, call `graph_register_edit(files: ["path/to/file"])`. The parameter is - `files` (plural, always an array). Use `file::symbol` notation when the edit targets - a specific function, class, or hook. - - ---- # Resgrid Project Guide @@ -126,15 +79,42 @@ Each layer depends only on the layer(s) to its left: - **Providers** (`Resgrid.Providers.*`): External integrations — depends on Model - **Web/Workers**: Entry points — depend on everything -### Dependency Injection (Autofac + Service Locator) +### Dependency Injection (Autofac) -This codebase uses **Service Locator** pattern, NOT constructor injection: +**Constructor injection is the convention.** Services, repositories, providers, controllers +(MVC and v4 API), and hubs all declare their dependencies as constructor parameters and let +Autofac supply them. When a type needs a new dependency, add a constructor parameter — do NOT +reach for the service locator. Existing constructors are large (e.g. `CommunicationTestService` +takes 18 parameters, `DispatchController` 30); that is deliberate and expected, and it is what +keeps these types unit-testable with mocks. ```csharp -// How services are resolved throughout the codebase: -var service = Bootstrapper.GetKernel().Resolve(); +// The convention — constructor injection: +public class SomethingService : ISomethingService +{ + private readonly IDepartmentsService _departmentsService; + + public SomethingService(IDepartmentsService departmentsService) + { + _departmentsService = departmentsService; + } +} ``` +**Service Locator is the exception, not the rule.** `Bootstrapper.GetKernel().Resolve()` is +reserved for the specific places where no DI container is available at the call site or where a +container-managed constructor cannot be used: + +- Worker logic under `Workers/Resgrid.Workers.Framework/Logic/` (queue consumers constructed by + the job host, not by Autofac). +- Static helpers and extension methods that have no constructor to inject into. +- Deliberate lazy escapes from a construction-time dependency cycle — and even then prefer + `Lazy` as a constructor parameter (see `CallsService`'s `Lazy`) + over a service-locator call. + +If you find yourself adding `Bootstrapper.GetKernel().Resolve()` anywhere else, use a +constructor parameter instead. + The `Bootstrapper` class (in `Resgrid.Workers.Framework/Bootstrapper.cs`) initializes Autofac with module-based registration: ```csharp var builder = new ContainerBuilder(); @@ -241,9 +221,9 @@ Task type discrimination uses `(int)TaskTypes.SomeEnum`. When Billing API is configured but returns a response where `Data.Data` is null, `GetCurrentPlanForDepartmentAsync` returns null instead of the free plan fallback. Callers that access `plan.PlanId` or `plan.GetLimitForTypeAsInt()` will NRE. -### 3. Service Locator in Constructors +### 3. Injected Dependencies Are Never Null -Unlike modern DI, this codebase resolves dependencies explicitly in constructors via `Bootstrapper.GetKernel().Resolve()`. When examining stack traces, dependencies are never null due to constructor injection failures — the Bootstrapper would fail at app start. If a NullReferenceException occurs on a service call, the issue is typically in the return value of the called method, not the service reference itself. +Dependencies come from Autofac constructor injection, so they are never null at a call site — a missing registration fails at container build (app start), not at the point of use. If a NullReferenceException occurs on a service call, the issue is almost always in the **return value** of the called method, not the service reference itself. The same holds for the worker paths that use `Bootstrapper.GetKernel().Resolve()`: an unregistered type throws a resolution exception rather than handing back null. ### 4. Async State Machine Line Numbers @@ -274,6 +254,31 @@ Plan limits are cached for **14 days** (`TimeSpan.FromDays(14)`). Most user/depa | Worker logic | `Workers/Resgrid.Workers.Framework/Logic/` | | Worker queue items | `Core/Resgrid.Model/Queue/` | +## Source Control + +**Agents never run `git commit` and never run `git push`. There is no exception, including +being asked to.** + +The commit is a human verification gate in front of the PR process. Its value comes from a +person having read the change and chosen to record it — an agent commit destroys that, and the +gate cannot be reconstructed afterwards. A request to commit is also not reliable evidence of +intent: it may be a typo, an accidental autocompletion, or a stale line in a longer message. +Because the gate exists precisely to catch what nobody meant to do, the request itself is not +sufficient authorization, no matter how it is phrased or how many times it is repeated. + +- **Never `git commit`.** Not when asked, not when a task is finished, not when the build is + green, not "so CI can run". If asked, decline, say why, and hand over the command. +- **Never `git push`.** Same rule, same reasoning, and worse consequences: once it is on the + remote, CI has run and reviewers may have seen it. +- **Never rewrite or delete published history** — no `push --force`, no `--force-with-lease`, + no remote branch deletion. +- **Never stage-and-commit indirectly either** — no `git commit -am`, no `git revert`, no + `git cherry-pick`, no amend, no `gh pr create`, no alias or script that ends in a commit. +- Leaving work uncommitted **is** the finished state. Report what changed, why, and the commit + message you would suggest. The user reads the diff and commits it themselves. + +Changing this rule is a deliberate edit to this file, not something granted in conversation. + ## Common Tasks **Build the entire solution:** diff --git a/Core/Resgrid.Config/DataProtectionConfig.cs b/Core/Resgrid.Config/DataProtectionConfig.cs index f87e68266..5b564d137 100644 --- a/Core/Resgrid.Config/DataProtectionConfig.cs +++ b/Core/Resgrid.Config/DataProtectionConfig.cs @@ -95,6 +95,21 @@ public static class DataProtectionConfig /// OpenBao HTTP request timeout in milliseconds; unwrap/wrap fail closed on expiry. public static int OpenBaoTimeoutMs = 10000; + /// + /// Response-boundary net (plan section 7.5): scan outgoing models of a PROTECTED department + /// for values that still carry an envelope and redact them. Defence in depth behind the + /// per-surface resolve calls, not a replacement for them. Costs one cached protection lookup + /// for every other department. Operator kill switch if the walk ever proves too expensive on + /// a hot path. + /// + public static bool EgressScanEnabled = true; + + /// + /// Node ceiling for one response scan. A graph larger than this is reported as truncated + /// rather than walked to the end — a silent cap would read as "nothing found". + /// + public static int EgressScanMaxNodes = 20000; + /// Default Protected Data Grant lifetime in minutes when a department has no policy value. public static int StepUpWindowDefaultMinutes = 15; diff --git a/Core/Resgrid.Config/UrlsConfig.cs b/Core/Resgrid.Config/UrlsConfig.cs index d0e36dd99..2a2595d04 100644 --- a/Core/Resgrid.Config/UrlsConfig.cs +++ b/Core/Resgrid.Config/UrlsConfig.cs @@ -11,6 +11,6 @@ public static class UrlsConfig public static string HomeUrl = "https://resgrid.com"; - public static string SystemStatusPageUrl = "https://resgrid.freshstatus.io"; + public static string SystemStatusPageUrl = "https://oneuptime.resgrid.net/status-page/09d6b850-e50e-42e2-8497-9ab55e5be465"; } } diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.ar.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.ar.resx new file mode 100644 index 000000000..74bbdfc8e --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.ar.resx @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + حماية البيانات المتقدمة + + + الرئيسية + + + الأمان + + + نافذة الترحيل نشطة. + + + إدخال البيانات متوقف مؤقتًا حتى إغلاق النافذة؛ العرض غير متأثر. + + + حالة الحماية + + + الحالة الحالية: + + + التسجيل في حماية البيانات المتقدمة غير متاح مؤقتًا. إذا كانت إدارتك قد اشترت الإضافة بالفعل، فهي تظل سارية — تحقق مرة أخرى قريبًا. + + + تقوم حماية البيانات المتقدمة بتشفير بيانات الإرسال والأفراد وجهات الاتصال الحساسة الخاصة بإدارتك بمفاتيح تملكها الإدارة. وهي تتطلب إضافة ADP السنوية. + + + شراء إضافة ADP + + + يمكن فقط للعضو المسؤول عن إدارتك شراء الإضافة والتسجيل. + + + إضافة ADP نشطة. يمكن فقط للعضو المسؤول عن إدارتك إكمال التسجيل. + + + التسجيل في قائمة الانتظار. يتم الترحيل خلال النافذة الليلية المحددة ({0}–{1}، {2}). ستتلقى رسالة بريد إلكتروني عند فتح نافذة كل ليلة وإغلاقها. + + + إلغاء التسجيل في قائمة الانتظار + + + الترحيل قيد التنفيذ. يجري العمل فقط داخل نافذتك الليلية ({0}–{1}، {2})؛ وخارجها تعمل إدارتك بكامل الخدمة. + + + حماية البيانات المتقدمة نشطة. + + + يتم تشفير الحقول المحمية بمفاتيح إدارتك؛ ويتطلب عرضها عضوًا مسجّل الدخول مع تحقق حديث من تطبيق المصادقة. + + + لإيقاف ADP، ألغِ الإضافة من صفحة الاشتراك. تبقى الحماية نشطة حتى نهاية فترة الفوترة الحالية. وبعد فك تشفير بياناتك، يتطلب تشغيلها مجددًا شراء الإضافة من جديد وإكمال تسجيل جديد. + + + صفحة الاشتراك + + + تم جدولة إنهاء التفعيل. + + + تبقى الحماية نشطة بالكامل حتى {0}، حيث يتم فك تشفير بياناتك وإعادتها إلى التخزين القياسي عبر نافذة ليلية واحدة أو أكثر. + + + نهاية فترة الفوترة الخاصة بك + + + الإبقاء على حماية البيانات المتقدمة (إلغاء إنهاء التفعيل) + + + إنهاء التفعيل قيد التنفيذ. تظل الحماية سارية حتى تتم استعادة بياناتك بالكامل إلى التخزين القياسي. + + + تعذّر إكمال آخر عملية ترحيل وسيتم استئنافها بعد المراجعة. إدارتك تعمل بكامل الخدمة وبياناتك آمنة. تم تنبيه الدعم؛ ولا يلزم أي إجراء. + + + معالج التسجيل + + + متابعة + + + رجوع + + + الخطوة 1 من 6 — ما الذي تغطيه حماية البيانات المتقدمة + + + يقوم ADP بتشفير المحتوى الحساس لبيانات إدارتك بمفاتيح تملكها الإدارة ومحفوظة في خدمة مفاتيح مُحصّنة: + + + محمي: + + + أسماء البلاغات وطبيعتها وملاحظاتها وعناوينها؛ ملاحظات البلاغات ومرفقاتها؛ بيانات جهات الاتصال وملاحظاتها؛ البيانات الحساسة للأعضاء. + + + يبقى بنص عادي: + + + معرّفات النظام وأرقام البلاغات والأولويات والحالات والطوابع الزمنية وأسماء الوحدات وهيكل الإدارة — تحتاجها المنصة لتوجيه العمل وعرضه. + + + أثناء الحماية: + + + لا يمكن للبحث والتقارير والتصدير وتكاملات الجهات الخارجية رؤية المحتوى المحمي. + + + تعرض شاشات Big Board هيكلًا مختصرًا باسم «حادث محمي» بدلًا من تفاصيل البلاغ. + + + تتلقى مسارات العمل حمولات محجوبة (تُستبدل القيم المحمية بـ REDACTED). + + + ترسل إشعارات الرسائل النصية والبريد الإلكتروني والدفع والصوت محتوى عامًا افتراضيًا («يتوفر إرسال محمي — سجّل الدخول إلى Resgrid»). + + + حماية البيانات المتقدمة وحدها ليست امتثالًا لـ HIPAA أو ePCR. إنها أحد الضوابط ضمن برنامج امتثال تظل مسؤوليته على عاتق جهتك. + + + الخطوة 2 من 6 — الإقرارات + + + يجب على العضو المسؤول الإقرار بكل بند أدناه. تُسجَّل إقراراتك مع ختم إصدار. + + + أفهم أي عائلات البيانات محمية (البلاغات، ملاحظات/مرفقات البلاغات، جهات الاتصال، البيانات الحساسة للأعضاء) وأن كتالوج الحقول المحمية له إصدارات. + + + أفهم أن بيانات النظام الوصفية (أرقام البلاغات، الأولويات، الحالات، الطوابع الزمنية، هيكل الوحدات والإدارة) تبقى بنص عادي. + + + أفهم أن خوادم Resgrid تعالج القيم المحمية في الذاكرة عندما يعرضها عضو مصرَّح له أو يعدّلها. + + + أفهم أن الأعضاء يحتاجون إلى تحقق حديث من تطبيق المصادقة (MFA) لعرض القيم المحمية، ضمن نافذة تحددها إدارتي (الافتراضي 15 دقيقة؛ والقيم التي تتجاوز 60 تتطلب سببًا مسجلًا). + + + أفهم أن شاشات Big Board تعرض هياكل مختصرة للحوادث المحمية. + + + أفهم أن مسارات العمل تتلقى حمولات محجوبة ولا يمكنها التصرف بناءً على المحتوى المحمي. + + + أفهم أن إشعارات الرسائل/البريد/الدفع/الصوت ترسل محتوى عامًا افتراضيًا، وأن تخفيف قيود أي قناة يتطلب تغيير سياسة مُقرًّا به بشكل منفصل. + + + أفهم أن البحث والتقارير والتصدير والتكاملات والوصول دون اتصال لا يمكنها رؤية المحتوى المحمي. + + + أفهم أن التفعيل يشغّل ترحيلًا ليليًا، وأن إيقافه لاحقًا يفك تشفير بياناتي عبر نوافذ ليلية في نهاية فترة الفوترة. + + + أفهم أن دعم Resgrid لا يمكنه قراءة القيم المحمية دون تصريح دعم صريح ومُدقَّق ومعتمد من الإدارة، وأن فقدان المفاتيح لا يمكن استرداده إلا عبر عملية الاسترداد الموثقة. + + + أفهم أن حماية البيانات المتقدمة وحدها ليست امتثالًا لـ HIPAA/ePCR. + + + الخطوة 3 من 6 — الفحوصات التمهيدية + + + العضو المسؤول + + + خطة مدفوعة + + + إضافة ADP نشطة + + + توافر التسجيل (بوابة المنصة) + + + حالة الإدارة تسمح بالتسجيل + + + خدمة الحماية قابلة للوصول + + + العضو المسؤول لديه تطبيق مصادقة مُفعّل + + + فشل أحد الفحوصات التمهيدية. عالج العناصر المعلَّمة بـ ✖ وأعد تحميل الصفحة — لا يمكن وضع التسجيل في قائمة الانتظار حتى تنجح كل الفحوصات. + + + الخطوة 4 من 6 — تقدير حجم الترحيل + + + يقوم فحص للقراءة فقط بعدّ صفوف إدارتك وتقدير عدد النوافذ الليلية التي يحتاجها الترحيل. هذا الفحص لا يغيّر شيئًا. + + + تشغيل فحص الحجم + + + الخطوة 5 من 6 — نافذة الترحيل الليلية + + + يتم الترحيل فقط داخل هذه النافذة المحلية للإدارة ويوقف إدخال البيانات مؤقتًا أثناء التشغيل. اختر أهدأ ساعاتك؛ الافتراضي هو {0}–{1}. + + + بداية النافذة (محلي) + + + نهاية النافذة (محلي) + + + المنطقة الزمنية + + + إيقاف مؤقت لعمليات الإدارة. + + + أثناء تشغيل نافذة الترحيل، لا تُنفَّذ بلاغات جديدة أو تغييرات حالة أو تغييرات ملاك أو مهام مجدولة لإدارتك. يستمر العرض. يمكنك إيقاف نافذة نشطة في أي وقت وتعود الإدارة فورًا إلى كامل الخدمة. + + + أفهم وأوافق على الإيقاف الليلي المؤقت للعمليات أثناء نوافذ الترحيل. + + + الخطوة 6 من 6 — التأكيد ووضعه في قائمة الانتظار + + + وضع التسجيل في قائمة الانتظار يعيد التحقق من كل متطلب على الخادم ويضع إدارتك في قائمة انتظار الترحيل. تُرحَّل الإدارات واحدة تلو الأخرى؛ وستتلقى بريدًا إلكترونيًا بجدولك. + + + وضع التسجيل في قائمة الانتظار + + + يجب تحديد كل الإقرارات قبل وضع التسجيل في قائمة الانتظار. + + + يجب الموافقة على الإيقاف الليلي المؤقت للعمليات قبل وضع التسجيل في قائمة الانتظار. + + + يمكن فقط للعضو المسؤول عن الإدارة تنفيذ هذا الأمر. + + + مطلوب إضافة نشطة لحماية البيانات المتقدمة. + + + تتطلب حماية البيانات المتقدمة خطة مدفوعة. + + + التسجيل في حماية البيانات المتقدمة غير متاح مؤقتًا. + + + حالة الحماية لدى الإدارة لا تسمح بهذا الأمر. أعد تحميل الصفحة لمعرفة الحالة الحالية. + + + مطلوب منطقة زمنية صالحة لنافذة الترحيل. + + + تعذّر إكمال الأمر؛ ويمكن إعادة المحاولة. + + + إلغاء التسجيل في قائمة الانتظار؟ لم يتم ترحيل أي شيء بعد؛ ويمكنك التسجيل مرة أخرى لاحقًا طالما أن الإضافة نشطة. + + + الإبقاء على حماية البيانات المتقدمة نشطة؟ سيتم إلغاء إنهاء التفعيل المجدول. + + + جهات الاتصال في حالات الطوارئ + + + جهات الاتصال في حالات الطوارئ خاصة بهذه الإدارة وتُخزَّن وفق إعدادات حماية البيانات لديها. يمكنك إضافة أكثر من واحدة. + + + إضافة جهة اتصال للطوارئ + + + تعديل جهة اتصال الطوارئ + + + جهة اتصال للطوارئ + + + لم تتم إضافة جهات اتصال للطوارئ. + + + الاسم + + + صلة القرابة + + + رقم الهاتف + + + رقم هاتف بديل + + + البريد الإلكتروني + + + ملاحظات + + + جهة الاتصال الأساسية + + + أساسي + + + بديل + + + نعم + + + تعديل + + + حذف + + + حفظ + + + إلغاء + + + الاسم مطلوب. + + + تعذّر حفظ جهة الاتصال. + + + إزالة جهة اتصال الطوارئ هذه؟ + + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.cs b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.cs new file mode 100644 index 000000000..ac8d22600 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.cs @@ -0,0 +1,11 @@ +namespace Resgrid.Localization.Areas.User.DataProtection +{ + /// + /// Marker type used by ASP.NET Core localization for Advanced Data Protection screens: the + /// enrollment wizard, the protection status panel, and the emergency-contact editor on the + /// profile page. These render in the request culture through IStringLocalizer. + /// + public class DataProtection + { + } +} diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.de.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.de.resx new file mode 100644 index 000000000..83fc3eeb6 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.de.resx @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Erweiterter Datenschutz + + + Startseite + + + Sicherheit + + + Migrationsfenster aktiv. + + + Die Dateneingabe ist pausiert, bis das Fenster schließt; die Anzeige ist nicht betroffen. + + + Schutzstatus + + + Aktueller Status: + + + Die Anmeldung für den erweiterten Datenschutz ist vorübergehend nicht verfügbar. Wenn Ihre Abteilung das Add-on bereits gekauft hat, bleibt es gültig — schauen Sie bald wieder vorbei. + + + Der erweiterte Datenschutz verschlüsselt die sensiblen Einsatz-, Personal- und Kontaktdaten Ihrer Abteilung mit Schlüsseln, die Ihrer Abteilung gehören. Er erfordert das jährliche ADP-Add-on. + + + ADP-Add-on kaufen + + + Nur das verwaltende Mitglied Ihrer Abteilung kann das Add-on kaufen und die Anmeldung durchführen. + + + Das ADP-Add-on ist aktiv. Nur das verwaltende Mitglied Ihrer Abteilung kann die Anmeldung abschließen. + + + Die Anmeldung ist eingereiht. Die Migration läuft im gewählten Nachtfenster ({0}–{1}, {2}). Sie erhalten eine E-Mail, wenn das Fenster jeder Nacht öffnet und schließt. + + + Eingereihte Anmeldung abbrechen + + + Die Migration läuft. Die Arbeit findet nur innerhalb Ihres Nachtfensters statt ({0}–{1}, {2}); außerhalb ist Ihre Abteilung voll einsatzfähig. + + + Der erweiterte Datenschutz ist aktiv. + + + Geschützte Felder werden mit den Schlüsseln Ihrer Abteilung verschlüsselt; zum Anzeigen ist ein angemeldetes Mitglied mit einer frischen Authenticator-Bestätigung erforderlich. + + + Um ADP zu deaktivieren, kündigen Sie das Add-on auf der Abonnementseite. Der Schutz bleibt bis zum Ende Ihres aktuellen Abrechnungszeitraums aktiv. Nach der Entschlüsselung Ihrer Daten erfordert eine erneute Aktivierung den erneuten Kauf des Add-ons und eine neue Anmeldung. + + + Abonnementseite + + + Die Deaktivierung ist geplant. + + + Der Schutz bleibt bis {0} vollständig aktiv; dann werden Ihre Daten über ein oder mehrere Nachtfenster zurück in den Standardspeicher entschlüsselt. + + + dem Ende Ihres Abrechnungszeitraums + + + Erweiterten Datenschutz behalten (Deaktivierung widerrufen) + + + Die Deaktivierung läuft. Der Schutz bleibt wirksam, bis Ihre Daten vollständig in den Standardspeicher zurückgeführt sind. + + + Der letzte Migrationslauf konnte nicht abgeschlossen werden und wird nach Prüfung fortgesetzt. Ihre Abteilung ist voll einsatzfähig und Ihre Daten bleiben sicher. Der Support wurde benachrichtigt; es ist nichts zu tun. + + + Anmeldeassistent + + + Weiter + + + Zurück + + + Schritt 1 von 6 — Was der erweiterte Datenschutz abdeckt + + + ADP verschlüsselt die sensiblen Inhalte der Daten Ihrer Abteilung mit Schlüsseln, die Ihrer Abteilung gehören und in einem gehärteten Schlüsseldienst liegen: + + + Geschützt: + + + Einsatznamen, -arten, -notizen und -adressen; Einsatznotizen und Anhänge; Kontaktdaten und Notizen; sensible Mitgliedsdaten. + + + Bleibt im Klartext: + + + Systemkennungen, Einsatznummern, Prioritäten, Status, Zeitstempel, Einheitennamen und Abteilungsstruktur — die Plattform benötigt diese zum Weiterleiten und Anzeigen der Arbeit. + + + Während des Schutzes: + + + Suche, Berichte, Exporte und Drittanbieter-Integrationen können geschützte Inhalte nicht sehen. + + + Big-Board-Anzeigen zeigen statt Einsatzdetails eine reduzierte Hülle „geschützter Einsatz“. + + + Workflows erhalten redigierte Daten (geschützte Werte werden durch REDACTED ersetzt). + + + SMS-, E-Mail-, Push- und Sprachbenachrichtigungen senden standardmäßig generische Inhalte („Ein geschützter Einsatz liegt vor — bei Resgrid anmelden“). + + + Der erweiterte Datenschutz allein ist keine HIPAA- oder ePCR-Konformität. Er ist eine Maßnahme innerhalb eines Compliance-Programms, das weiterhin Ihrer Organisation obliegt. + + + Schritt 2 von 6 — Bestätigungen + + + Jeder Punkt unten muss vom verwaltenden Mitglied bestätigt werden. Ihre Bestätigungen werden mit einem Versionsstempel aufgezeichnet. + + + Ich verstehe, welche Datenfamilien geschützt sind (Einsätze, Einsatznotizen/Anhänge, Kontakte, sensible Mitgliedsdaten) und dass der Katalog geschützter Felder versioniert ist. + + + Ich verstehe, dass Systemmetadaten (Einsatznummern, Prioritäten, Status, Zeitstempel, Einheiten- und Abteilungsstruktur) im Klartext bleiben. + + + Ich verstehe, dass die Server von Resgrid geschützte Werte im Arbeitsspeicher verarbeiten, wenn ein berechtigtes Mitglied sie ansieht oder bearbeitet. + + + Ich verstehe, dass Mitglieder eine frische Authenticator-Bestätigung (MFA) benötigen, um geschützte Werte anzuzeigen, innerhalb eines von meiner Abteilung konfigurierten Fensters (Standard 15 Minuten; Werte über 60 erfordern eine dokumentierte Begründung). + + + Ich verstehe, dass Big-Board-Anzeigen reduzierte Hüllen für geschützte Einsätze zeigen. + + + Ich verstehe, dass Workflows redigierte Daten erhalten und nicht auf geschützte Inhalte reagieren können. + + + Ich verstehe, dass SMS-/E-Mail-/Push-/Sprachbenachrichtigungen standardmäßig generische Inhalte senden und das Lockern eines Kanals eine separate, bestätigte Richtlinienänderung erfordert. + + + Ich verstehe, dass Suche, Berichte, Exporte, Integrationen und Offline-Zugriff geschützte Inhalte nicht sehen können. + + + Ich verstehe, dass die Aktivierung eine nächtliche Migration auslöst und eine spätere Deaktivierung meine Daten am Ende des Abrechnungszeitraums über Nachtfenster entschlüsselt. + + + Ich verstehe, dass der Resgrid-Support geschützte Werte nicht ohne eine ausdrückliche, protokollierte und von der Abteilung genehmigte Support-Freigabe lesen kann und dass ein Schlüsselverlust nur über den dokumentierten Wiederherstellungsprozess behebbar ist. + + + Ich verstehe, dass der erweiterte Datenschutz allein keine HIPAA/ePCR-Konformität darstellt. + + + Schritt 3 von 6 — Vorabprüfungen + + + Verwaltendes Mitglied + + + Kostenpflichtiger Tarif + + + Aktives ADP-Add-on + + + Anmeldeverfügbarkeit (Plattform-Freigabe) + + + Abteilungsstatus erlaubt die Anmeldung + + + Schutzdienst erreichbar + + + Verwaltendes Mitglied hat einen Authenticator eingerichtet + + + Eine Vorabprüfung ist fehlgeschlagen. Beheben Sie die mit ✖ markierten Punkte und laden Sie diese Seite neu — die Anmeldung kann erst eingereiht werden, wenn alle Prüfungen bestanden sind. + + + Schritt 4 von 6 — Schätzung des Migrationsumfangs + + + Ein reiner Lesescan zählt die Datensätze Ihrer Abteilung und schätzt, wie viele Nachtfenster die Migration benötigt. Dieser Scan ändert nichts. + + + Umfangsscan ausführen + + + Schritt 5 von 6 — Nächtliches Migrationsfenster + + + Die Migration läuft nur in diesem abteilungslokalen Fenster und pausiert währenddessen die Dateneingabe. Wählen Sie Ihre ruhigsten Stunden; Standard ist {0}–{1}. + + + Fensterbeginn (lokal) + + + Fensterende (lokal) + + + Zeitzone + + + Betriebspause der Abteilung. + + + Während ein Migrationsfenster läuft, werden für Ihre Abteilung keine neuen Einsätze, Statusänderungen, Personaländerungen oder geplanten Aufgaben ausgeführt. Die Anzeige bleibt möglich. Sie können ein aktives Fenster jederzeit abbrechen; die Abteilung ist sofort wieder voll einsatzfähig. + + + Ich verstehe die nächtliche Betriebspause während der Migrationsfenster und stimme ihr zu. + + + Schritt 6 von 6 — Bestätigen und einreihen + + + Das Einreihen der Anmeldung überprüft alle Voraussetzungen serverseitig erneut und stellt Ihre Abteilung in die Migrationswarteschlange. Abteilungen migrieren nacheinander; Sie erhalten eine E-Mail mit Ihrem Zeitplan. + + + Anmeldung einreihen + + + Alle Bestätigungen müssen angehakt sein, bevor die Anmeldung eingereiht werden kann. + + + Der nächtlichen Betriebspause muss zugestimmt werden, bevor die Anmeldung eingereiht werden kann. + + + Nur das verwaltende Mitglied der Abteilung darf diesen Befehl ausführen. + + + Ein aktives Add-on für den erweiterten Datenschutz ist erforderlich. + + + Der erweiterte Datenschutz erfordert einen kostenpflichtigen Tarif. + + + Die Anmeldung für den erweiterten Datenschutz ist vorübergehend nicht verfügbar. + + + Der Schutzstatus der Abteilung lässt diesen Befehl nicht zu. Laden Sie die Seite neu, um den aktuellen Status zu sehen. + + + Eine gültige Zeitzone für das Migrationsfenster ist erforderlich. + + + Der Befehl konnte nicht abgeschlossen werden; er kann wiederholt werden. + + + Eingereihte Anmeldung abbrechen? Es wurde noch nichts migriert; Sie können sich später erneut anmelden, solange das Add-on aktiv ist. + + + Erweiterten Datenschutz aktiv lassen? Die geplante Deaktivierung wird abgebrochen. + + + Notfallkontakte + + + Notfallkontakte gelten speziell für diese Abteilung und werden mit den Datenschutzeinstellungen Ihrer Abteilung gespeichert. Sie können mehrere hinzufügen. + + + Notfallkontakt hinzufügen + + + Notfallkontakt bearbeiten + + + Notfallkontakt + + + Es wurden keine Notfallkontakte hinzugefügt. + + + Name + + + Beziehung + + + Telefonnummer + + + Alternative Telefonnummer + + + E-Mail + + + Notizen + + + Hauptkontakt + + + Primär + + + Alternativ + + + Ja + + + Bearbeiten + + + Löschen + + + Speichern + + + Abbrechen + + + Ein Name ist erforderlich. + + + Der Kontakt konnte nicht gespeichert werden. + + + Diesen Notfallkontakt entfernen? + + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.el.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.el.resx new file mode 100644 index 000000000..827765ee4 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.el.resx @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Προηγμένη προστασία δεδομένων + + + Αρχική + + + Ασφάλεια + + + Το παράθυρο μετεγκατάστασης είναι ενεργό. + + + Η καταχώριση δεδομένων είναι σε παύση μέχρι να κλείσει το παράθυρο· η προβολή δεν επηρεάζεται. + + + Κατάσταση προστασίας + + + Τρέχουσα κατάσταση: + + + Η εγγραφή στην προηγμένη προστασία δεδομένων δεν είναι προσωρινά διαθέσιμη. Εάν το τμήμα σας έχει ήδη αγοράσει το πρόσθετο, παραμένει έγκυρο — ελέγξτε ξανά σύντομα. + + + Η προηγμένη προστασία δεδομένων κρυπτογραφεί τα ευαίσθητα δεδομένα αποστολής, προσωπικού και επαφών του τμήματός σας με κλειδιά που ανήκουν στο τμήμα. Απαιτεί το ετήσιο πρόσθετο ADP. + + + Αγορά του πρόσθετου ADP + + + Μόνο το διαχειριστικό μέλος του τμήματός σας μπορεί να αγοράσει το πρόσθετο και να κάνει εγγραφή. + + + Το πρόσθετο ADP είναι ενεργό. Μόνο το διαχειριστικό μέλος του τμήματός σας μπορεί να ολοκληρώσει την εγγραφή. + + + Η εγγραφή είναι σε ουρά. Η μετεγκατάσταση εκτελείται στο επιλεγμένο νυχτερινό παράθυρο ({0}–{1}, {2}). Θα λάβετε email όταν το παράθυρο κάθε νύχτας ανοίγει και κλείνει. + + + Ακύρωση εγγραφής σε ουρά + + + Η μετεγκατάσταση βρίσκεται σε εξέλιξη. Η εργασία γίνεται μόνο εντός του νυχτερινού παραθύρου ({0}–{1}, {2})· εκτός αυτού το τμήμα σας λειτουργεί πλήρως. + + + Η προηγμένη προστασία δεδομένων είναι ενεργή. + + + Τα προστατευμένα πεδία κρυπτογραφούνται με τα κλειδιά του τμήματός σας· η προβολή τους απαιτεί συνδεδεμένο μέλος με πρόσφατη επαλήθευση ελέγχου ταυτότητας. + + + Για να απενεργοποιήσετε το ADP, ακυρώστε το πρόσθετο στη σελίδα συνδρομής. Η προστασία παραμένει ενεργή έως το τέλος της τρέχουσας περιόδου χρέωσης. Μετά την αποκρυπτογράφηση των δεδομένων σας, η εκ νέου ενεργοποίηση απαιτεί νέα αγορά του πρόσθετου και νέα εγγραφή. + + + σελίδα συνδρομής + + + Η απενεργοποίηση έχει προγραμματιστεί. + + + Η προστασία παραμένει πλήρως ενεργή έως {0}, οπότε τα δεδομένα σας αποκρυπτογραφούνται πίσω σε τυπική αποθήκευση σε ένα ή περισσότερα νυχτερινά παράθυρα. + + + το τέλος της περιόδου χρέωσής σας + + + Διατήρηση προηγμένης προστασίας δεδομένων (ανάκληση απενεργοποίησης) + + + Η απενεργοποίηση βρίσκεται σε εξέλιξη. Η προστασία παραμένει σε ισχύ μέχρι τα δεδομένα σας να αποκατασταθούν πλήρως σε τυπική αποθήκευση. + + + Η τελευταία εκτέλεση μετεγκατάστασης δεν ολοκληρώθηκε και θα συνεχιστεί μετά από έλεγχο. Το τμήμα σας λειτουργεί πλήρως και τα δεδομένα σας παραμένουν ασφαλή. Η υποστήριξη ειδοποιήθηκε· δεν απαιτείται καμία ενέργεια. + + + Οδηγός εγγραφής + + + Συνέχεια + + + Πίσω + + + Βήμα 1 από 6 — Τι καλύπτει η προηγμένη προστασία δεδομένων + + + Το ADP κρυπτογραφεί το ευαίσθητο περιεχόμενο των δεδομένων του τμήματός σας με κλειδιά που ανήκουν στο τμήμα και φυλάσσονται σε θωρακισμένη υπηρεσία κλειδιών: + + + Προστατευμένα: + + + ονόματα, φύση, σημειώσεις και διευθύνσεις κλήσεων· σημειώσεις και συνημμένα κλήσεων· στοιχεία και σημειώσεις επαφών· ευαίσθητα δεδομένα μελών. + + + Παραμένει σε απλό κείμενο: + + + αναγνωριστικά συστήματος, αριθμοί κλήσεων, προτεραιότητες, καταστάσεις, χρονοσημάνσεις, ονόματα μονάδων και δομή τμήματος — η πλατφόρμα τα χρειάζεται για δρομολόγηση και εμφάνιση. + + + Ενώ προστατεύεται: + + + Η αναζήτηση, οι αναφορές, οι εξαγωγές και οι ενσωματώσεις τρίτων δεν βλέπουν προστατευμένο περιεχόμενο. + + + Οι οθόνες Big Board εμφανίζουν μειωμένο κέλυφος «προστατευμένο συμβάν» αντί για λεπτομέρειες κλήσης. + + + Οι ροές εργασίας λαμβάνουν λογοκριμένα δεδομένα (οι προστατευμένες τιμές αντικαθίστανται με REDACTED). + + + Οι ειδοποιήσεις SMS, email, push και φωνής στέλνουν εξ ορισμού γενικό περιεχόμενο («Υπάρχει προστατευμένη αποστολή — συνδεθείτε στο Resgrid»). + + + Η προηγμένη προστασία δεδομένων από μόνη της δεν αποτελεί συμμόρφωση HIPAA ή ePCR. Είναι ένα μέτρο εντός ενός προγράμματος συμμόρφωσης που παραμένει ευθύνη του φορέα σας. + + + Βήμα 2 από 6 — Αποδοχές + + + Κάθε στοιχείο παρακάτω πρέπει να γίνει αποδεκτό από το διαχειριστικό μέλος. Οι αποδοχές καταγράφονται με σφραγίδα έκδοσης. + + + Κατανοώ ποιες οικογένειες δεδομένων προστατεύονται (κλήσεις, σημειώσεις/συνημμένα κλήσεων, επαφές, ευαίσθητα δεδομένα μελών) και ότι ο κατάλογος προστατευμένων πεδίων είναι εκδοσιοποιημένος. + + + Κατανοώ ότι τα μεταδεδομένα συστήματος (αριθμοί κλήσεων, προτεραιότητες, καταστάσεις, χρονοσημάνσεις, δομή μονάδων και τμήματος) παραμένουν σε απλό κείμενο. + + + Κατανοώ ότι οι διακομιστές της Resgrid επεξεργάζονται τις προστατευμένες τιμές στη μνήμη όταν ένα εξουσιοδοτημένο μέλος τις προβάλλει ή τις επεξεργάζεται. + + + Κατανοώ ότι τα μέλη χρειάζονται πρόσφατη επαλήθευση authenticator (MFA) για να δουν προστατευμένες τιμές, εντός παραθύρου που ρυθμίζει το τμήμα μου (προεπιλογή 15 λεπτά· τιμές άνω των 60 απαιτούν καταγεγραμμένη αιτιολογία). + + + Κατανοώ ότι οι οθόνες Big Board εμφανίζουν μειωμένα κελύφη προστατευμένων συμβάντων. + + + Κατανοώ ότι οι ροές εργασίας λαμβάνουν λογοκριμένα δεδομένα και δεν μπορούν να ενεργήσουν σε προστατευμένο περιεχόμενο. + + + Κατανοώ ότι οι ειδοποιήσεις SMS/email/push/φωνής στέλνουν εξ ορισμού γενικό περιεχόμενο και ότι η χαλάρωση ενός καναλιού απαιτεί ξεχωριστή αποδεκτή αλλαγή πολιτικής. + + + Κατανοώ ότι η αναζήτηση, οι αναφορές, οι εξαγωγές, οι ενσωματώσεις και η εκτός σύνδεσης πρόσβαση δεν βλέπουν προστατευμένο περιεχόμενο. + + + Κατανοώ ότι η ενεργοποίηση εκτελεί νυχτερινή μετεγκατάσταση και ότι η μετέπειτα απενεργοποίηση αποκρυπτογραφεί τα δεδομένα μου σε νυχτερινά παράθυρα στο τέλος της περιόδου χρέωσης. + + + Κατανοώ ότι η υποστήριξη της Resgrid δεν μπορεί να διαβάσει προστατευμένες τιμές χωρίς ρητή, ελεγμένη και εγκεκριμένη από το τμήμα άδεια υποστήριξης, και ότι η απώλεια κλειδιών ανακτάται μόνο μέσω της τεκμηριωμένης διαδικασίας. + + + Κατανοώ ότι η προηγμένη προστασία δεδομένων από μόνη της δεν αποτελεί συμμόρφωση HIPAA/ePCR. + + + Βήμα 3 από 6 — Προκαταρκτικοί έλεγχοι + + + Διαχειριστικό μέλος + + + Επί πληρωμή πρόγραμμα + + + Ενεργό πρόσθετο ADP + + + Διαθεσιμότητα εγγραφής (πύλη πλατφόρμας) + + + Η κατάσταση τμήματος επιτρέπει την εγγραφή + + + Η υπηρεσία προστασίας είναι προσβάσιμη + + + Το διαχειριστικό μέλος έχει καταχωρημένο authenticator + + + Ένας προκαταρκτικός έλεγχος απέτυχε. Επιλύστε τα στοιχεία με ✖ και φορτώστε ξανά τη σελίδα — η εγγραφή δεν μπορεί να μπει σε ουρά μέχρι να περάσουν όλοι οι έλεγχοι. + + + Βήμα 4 από 6 — Εκτίμηση μεγέθους μετεγκατάστασης + + + Μια σάρωση μόνο για ανάγνωση μετρά τις γραμμές του τμήματός σας και εκτιμά πόσα νυχτερινά παράθυρα χρειάζεται η μετεγκατάσταση. Η σάρωση δεν αλλάζει τίποτα. + + + Εκτέλεση σάρωσης μεγέθους + + + Βήμα 5 από 6 — Νυχτερινό παράθυρο μετεγκατάστασης + + + Η μετεγκατάσταση εκτελείται μόνο σε αυτό το τοπικό παράθυρο του τμήματος και θέτει σε παύση την καταχώριση δεδομένων. Επιλέξτε τις πιο ήσυχες ώρες· η προεπιλογή είναι {0}–{1}. + + + Έναρξη παραθύρου (τοπική) + + + Λήξη παραθύρου (τοπική) + + + Ζώνη ώρας + + + Παύση λειτουργίας τμήματος. + + + Ενώ εκτελείται παράθυρο μετεγκατάστασης, δεν εκτελούνται νέες κλήσεις, αλλαγές κατάστασης, αλλαγές στελέχωσης ή προγραμματισμένες εργασίες για το τμήμα σας. Η προβολή συνεχίζεται. Μπορείτε να διακόψετε ένα ενεργό παράθυρο ανά πάσα στιγμή και το τμήμα επιστρέφει αμέσως σε πλήρη λειτουργία. + + + Κατανοώ και συναινώ στη νυχτερινή παύση λειτουργίας κατά τα παράθυρα μετεγκατάστασης. + + + Βήμα 6 από 6 — Επιβεβαίωση και ουρά + + + Η τοποθέτηση της εγγραφής σε ουρά επαληθεύει ξανά κάθε απαίτηση στον διακομιστή και τοποθετεί το τμήμα σας στην ουρά μετεγκατάστασης. Τα τμήματα μετεγκαθίστανται ένα κάθε φορά· θα λάβετε email με το πρόγραμμά σας. + + + Εγγραφή σε ουρά + + + Όλες οι αποδοχές πρέπει να επιλεγούν πριν μπει η εγγραφή σε ουρά. + + + Πρέπει να συναινέσετε στη νυχτερινή παύση λειτουργίας πριν μπει η εγγραφή σε ουρά. + + + Μόνο το διαχειριστικό μέλος του τμήματος μπορεί να εκτελέσει αυτή την εντολή. + + + Απαιτείται ενεργό πρόσθετο προηγμένης προστασίας δεδομένων. + + + Η προηγμένη προστασία δεδομένων απαιτεί επί πληρωμή πρόγραμμα. + + + Η εγγραφή στην προηγμένη προστασία δεδομένων δεν είναι προσωρινά διαθέσιμη. + + + Η κατάσταση προστασίας του τμήματος δεν επιτρέπει αυτή την εντολή. Φορτώστε ξανά τη σελίδα για την τρέχουσα κατάσταση. + + + Απαιτείται έγκυρη ζώνη ώρας για το παράθυρο μετεγκατάστασης. + + + Η εντολή δεν ολοκληρώθηκε· μπορείτε να δοκιμάσετε ξανά. + + + Ακύρωση της εγγραφής σε ουρά; Δεν έχει μετεγκατασταθεί τίποτα ακόμη· μπορείτε να εγγραφείτε ξανά αργότερα όσο το πρόσθετο είναι ενεργό. + + + Διατήρηση της προηγμένης προστασίας δεδομένων ενεργής; Η προγραμματισμένη απενεργοποίηση θα ακυρωθεί. + + + Επαφές έκτακτης ανάγκης + + + Οι επαφές έκτακτης ανάγκης αφορούν αυτό το τμήμα και αποθηκεύονται με τις ρυθμίσεις προστασίας δεδομένων του τμήματος. Μπορείτε να προσθέσετε περισσότερες από μία. + + + Προσθήκη επαφής έκτακτης ανάγκης + + + Επεξεργασία επαφής έκτακτης ανάγκης + + + Επαφή έκτακτης ανάγκης + + + Δεν έχουν προστεθεί επαφές έκτακτης ανάγκης. + + + Όνομα + + + Σχέση + + + Αριθμός τηλεφώνου + + + Εναλλακτικός αριθμός τηλεφώνου + + + Email + + + Σημειώσεις + + + Κύρια επαφή + + + Κύρια + + + Εναλλακτικό + + + Ναι + + + Επεξεργασία + + + Διαγραφή + + + Αποθήκευση + + + Ακύρωση + + + Απαιτείται όνομα. + + + Η επαφή δεν μπόρεσε να αποθηκευτεί. + + + Κατάργηση αυτής της επαφής έκτακτης ανάγκης; + + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.en.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.en.resx new file mode 100644 index 000000000..33acb68f3 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.en.resx @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Advanced Data Protection + + + Home + + + Security + + + Migration window active. + + + Data entry is paused until the window closes; viewing is unaffected. + + + Protection Status + + + Current state: + + + Advanced Data Protection enrollment is temporarily unavailable. If your department has already purchased the addon, it remains valid — check back soon. + + + 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. + + + Purchase the ADP addon + + + Only your department's managing member can purchase the addon and enroll. + + + The ADP addon is active. Only your department's managing member can complete enrollment. + + + Enrollment is queued. Migration runs during your selected overnight window ({0}–{1}, {2}). You will receive an email when each night's window opens and closes. + + + Cancel queued enrollment + + + Migration is in progress. Work happens only inside your overnight window ({0}–{1}, {2}); your department is in full service outside it. + + + 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. + + + subscription page + + + Offboarding is scheduled. + + + Protection remains fully active until {0}, when your data is decrypted back to standard storage over one or more overnight windows. + + + the end of your billing period + + + Keep Advanced Data Protection (revoke offboarding) + + + Offboarding is in progress. Protection remains in effect until your data is fully restored to standard storage. + + + 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. + + + Enrollment Wizard + + + Continue + + + Back + + + 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 of 6 — Acknowledgements + + + Each item below must be acknowledged by the managing member. Your acknowledgements are recorded with a version stamp. + + + I understand which data families are protected (calls, call notes/attachments, contacts, member sensitive data) and that the protected-field catalog is versioned. + + + I understand system metadata (call numbers, priorities, statuses, timestamps, unit and department structure) stays plaintext. + + + I understand Resgrid's servers process protected values in memory when an authorized member views or edits them. + + + I understand members need a fresh authenticator (MFA) verification to view protected values, on a window my department configures (default 15 minutes; values over 60 require a recorded reason). + + + I understand Big Board displays show reduced, protected-incident shells. + + + I understand workflows receive redacted payloads and cannot act on protected content. + + + I understand text/email/push/voice notifications send generic content by default, and relaxing a channel requires a separate acknowledged policy change. + + + I understand search, reporting, exports, integrations and offline access cannot see protected content. + + + I understand enabling runs an overnight migration, and disabling later decrypts my data over overnight windows at the end of the billing period. + + + I understand Resgrid support cannot read protected values without an explicit, audited, department-approved support grant, and that key loss is recoverable only through the documented recovery process. + + + I understand Advanced Data Protection alone is not HIPAA/ePCR compliance. + + + Step 3 of 6 — Preflight checks + + + Managing member + + + Paid plan + + + Active ADP addon + + + Enrollment availability (platform gate) + + + Department state permits enrollment + + + Protection service reachable + + + Managing member has an authenticator enrolled + + + A preflight check failed. Resolve the items marked ✖ and reload this page — enrollment cannot be queued until every check passes. + + + Step 4 of 6 — Migration size estimate + + + A read-only scan counts your department's rows and estimates how many overnight windows the migration needs. Nothing is changed by this scan. + + + Run sizing scan + + + Step 5 of 6 — Overnight migration window + + + Migration runs only inside this department-local window and pauses data entry while running. Pick your quietest hours; the default is {0}–{1}. + + + Window start (local) + + + Window end (local) + + + Time zone + + + Department operation pause. + + + While a migration window is running, no new calls, status changes, staffing changes or scheduled tasks run for your department. Viewing continues. You can abort an active window at any time and the department returns to full service immediately. + + + I understand and consent to the overnight operation pause during migration windows. + + + Step 6 of 6 — Confirm and queue + + + Queueing enrollment re-verifies every requirement server-side and places your department in the migration queue. Departments migrate one at a time; you'll receive an email with your schedule. + + + Queue enrollment + + + Every acknowledgement must be checked before enrollment can be queued. + + + The overnight operation pause must be consented to before enrollment can be queued. + + + Only the department's managing member may run this command. + + + An active Advanced Data Protection addon is required. + + + Advanced Data Protection requires a paid plan. + + + Advanced Data Protection enrollment is temporarily unavailable. + + + The department's protection state does not permit this command. Reload the page for current status. + + + A valid migration window time zone is required. + + + The command could not be completed; it may be retried. + + + Cancel the queued enrollment? Nothing has been migrated yet; you can enroll again later while the addon is active. + + + Keep Advanced Data Protection active? The scheduled offboarding will be cancelled. + + + Emergency Contacts + + + Emergency contacts are specific to this department and are stored with your department's data protection settings. You can add more than one. + + + Add Emergency Contact + + + Edit Emergency Contact + + + Emergency Contact + + + No emergency contacts have been added. + + + Name + + + Relationship + + + Phone number + + + Alternate phone number + + + Email + + + Notes + + + Primary contact + + + Primary + + + Alternate + + + Yes + + + Edit + + + Delete + + + Save + + + Cancel + + + A name is required. + + + The contact could not be saved. + + + Remove this emergency contact? + + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.es.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.es.resx new file mode 100644 index 000000000..509735765 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.es.resx @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Protección de datos avanzada + + + Inicio + + + Seguridad + + + Ventana de migración activa. + + + La entrada de datos está en pausa hasta que se cierre la ventana; la visualización no se ve afectada. + + + Estado de protección + + + Estado actual: + + + La inscripción en la protección de datos avanzada no está disponible temporalmente. Si su departamento ya compró el complemento, sigue siendo válido: vuelva a consultar pronto. + + + La protección de datos avanzada cifra los datos sensibles de despacho, personal y contactos de su departamento con claves propiedad de su departamento. Requiere el complemento ADP anual. + + + Comprar el complemento ADP + + + Solo el miembro administrador de su departamento puede comprar el complemento e inscribirse. + + + El complemento ADP está activo. Solo el miembro administrador de su departamento puede completar la inscripción. + + + La inscripción está en cola. La migración se ejecuta durante la ventana nocturna seleccionada ({0}–{1}, {2}). Recibirá un correo cuando la ventana de cada noche se abra y se cierre. + + + Cancelar la inscripción en cola + + + La migración está en curso. El trabajo ocurre solo dentro de su ventana nocturna ({0}–{1}, {2}); fuera de ella su departamento está en pleno servicio. + + + La protección de datos avanzada está activa. + + + Los campos protegidos se cifran con las claves de su departamento; verlos requiere un miembro con sesión iniciada y una verificación reciente del autenticador. + + + Para desactivar ADP, cancele el complemento en la página de suscripción. La protección permanece activa hasta el final de su período de facturación actual. Tras descifrar sus datos, volver a activarla requiere comprar el complemento de nuevo y completar una nueva inscripción. + + + página de suscripción + + + La baja está programada. + + + La protección permanece totalmente activa hasta {0}, cuando sus datos se descifran de vuelta al almacenamiento estándar en una o más ventanas nocturnas. + + + el final de su período de facturación + + + Mantener la protección de datos avanzada (revocar la baja) + + + La baja está en curso. La protección sigue vigente hasta que sus datos se restauren por completo al almacenamiento estándar. + + + La última ejecución de la migración no pudo completarse y se reanudará tras la revisión. Su departamento está en pleno servicio y sus datos siguen seguros. Se ha avisado al soporte; no se requiere ninguna acción. + + + Asistente de inscripción + + + Continuar + + + Atrás + + + Paso 1 de 6: qué cubre la protección de datos avanzada + + + ADP cifra el contenido sensible de los datos de su departamento con claves de su propiedad, guardadas en un servicio de claves reforzado: + + + Protegido: + + + nombres, naturalezas, notas y direcciones de llamadas; notas y adjuntos de llamadas; datos y notas de contactos; datos sensibles de miembros. + + + Permanece en texto plano: + + + identificadores del sistema, números de llamada, prioridades, estados, marcas de tiempo, nombres de unidades y estructura del departamento: la plataforma los necesita para enrutar y mostrar el trabajo. + + + Mientras está protegido: + + + La búsqueda, los informes, las exportaciones y las integraciones de terceros no pueden ver el contenido protegido. + + + Las pantallas Big Board muestran un resumen reducido de «incidente protegido» en lugar de los detalles de la llamada. + + + Los flujos de trabajo reciben cargas redactadas (los valores protegidos se sustituyen por REDACTED). + + + Las notificaciones de texto, correo, push y voz envían contenido genérico de forma predeterminada («Hay un despacho protegido: inicie sesión en Resgrid»). + + + La protección de datos avanzada por sí sola no es cumplimiento de HIPAA ni ePCR. Es un control dentro de un programa de cumplimiento que sigue siendo responsabilidad de su organismo. + + + Paso 2 de 6: reconocimientos + + + Cada punto siguiente debe ser reconocido por el miembro administrador. Sus reconocimientos se registran con una marca de versión. + + + Entiendo qué familias de datos están protegidas (llamadas, notas/adjuntos de llamadas, contactos, datos sensibles de miembros) y que el catálogo de campos protegidos está versionado. + + + Entiendo que los metadatos del sistema (números de llamada, prioridades, estados, marcas de tiempo, estructura de unidades y departamento) permanecen en texto plano. + + + Entiendo que los servidores de Resgrid procesan los valores protegidos en memoria cuando un miembro autorizado los ve o edita. + + + Entiendo que los miembros necesitan una verificación reciente del autenticador (MFA) para ver los valores protegidos, en una ventana que configura mi departamento (predeterminado 15 minutos; valores superiores a 60 requieren un motivo registrado). + + + Entiendo que las pantallas Big Board muestran resúmenes reducidos de incidentes protegidos. + + + Entiendo que los flujos de trabajo reciben cargas redactadas y no pueden actuar sobre contenido protegido. + + + Entiendo que las notificaciones de texto/correo/push/voz envían contenido genérico de forma predeterminada, y relajar un canal requiere un cambio de política reconocido por separado. + + + Entiendo que la búsqueda, los informes, las exportaciones, las integraciones y el acceso sin conexión no pueden ver el contenido protegido. + + + Entiendo que activarlo ejecuta una migración nocturna, y desactivarlo más tarde descifra mis datos en ventanas nocturnas al final del período de facturación. + + + Entiendo que el soporte de Resgrid no puede leer valores protegidos sin una concesión de soporte explícita, auditada y aprobada por el departamento, y que la pérdida de claves solo se recupera mediante el proceso documentado. + + + Entiendo que la protección de datos avanzada por sí sola no es cumplimiento de HIPAA/ePCR. + + + Paso 3 de 6: comprobaciones previas + + + Miembro administrador + + + Plan de pago + + + Complemento ADP activo + + + Disponibilidad de inscripción (control de plataforma) + + + El estado del departamento permite la inscripción + + + Servicio de protección accesible + + + El miembro administrador tiene un autenticador registrado + + + Falló una comprobación previa. Resuelva los elementos marcados con ✖ y recargue esta página: la inscripción no puede ponerse en cola hasta que todas las comprobaciones pasen. + + + Paso 4 de 6: estimación del tamaño de la migración + + + Un escaneo de solo lectura cuenta las filas de su departamento y estima cuántas ventanas nocturnas necesita la migración. Este escaneo no cambia nada. + + + Ejecutar escaneo de tamaño + + + Paso 5 de 6: ventana de migración nocturna + + + La migración se ejecuta solo dentro de esta ventana local del departamento y pausa la entrada de datos mientras corre. Elija sus horas más tranquilas; el valor predeterminado es {0}–{1}. + + + Inicio de la ventana (local) + + + Fin de la ventana (local) + + + Zona horaria + + + Pausa de operaciones del departamento. + + + Mientras se ejecuta una ventana de migración, no se procesan nuevas llamadas, cambios de estado, cambios de personal ni tareas programadas para su departamento. La visualización continúa. Puede abortar una ventana activa en cualquier momento y el departamento vuelve de inmediato al servicio completo. + + + Entiendo y consiento la pausa nocturna de operaciones durante las ventanas de migración. + + + Paso 6 de 6: confirmar y poner en cola + + + Poner la inscripción en cola vuelve a verificar cada requisito en el servidor y coloca a su departamento en la cola de migración. Los departamentos migran de uno en uno; recibirá un correo con su programación. + + + Poner la inscripción en cola + + + Deben marcarse todos los reconocimientos antes de poner la inscripción en cola. + + + Debe consentirse la pausa nocturna de operaciones antes de poner la inscripción en cola. + + + Solo el miembro administrador del departamento puede ejecutar este comando. + + + Se requiere un complemento activo de protección de datos avanzada. + + + La protección de datos avanzada requiere un plan de pago. + + + La inscripción en la protección de datos avanzada no está disponible temporalmente. + + + El estado de protección del departamento no permite este comando. Recargue la página para ver el estado actual. + + + Se requiere una zona horaria válida para la ventana de migración. + + + No se pudo completar el comando; puede reintentarse. + + + ¿Cancelar la inscripción en cola? Aún no se ha migrado nada; puede inscribirse de nuevo más adelante mientras el complemento esté activo. + + + ¿Mantener activa la protección de datos avanzada? Se cancelará la baja programada. + + + Contactos de emergencia + + + Los contactos de emergencia son específicos de este departamento y se almacenan con la configuración de protección de datos de su departamento. Puede añadir más de uno. + + + Añadir contacto de emergencia + + + Editar contacto de emergencia + + + Contacto de emergencia + + + No se han añadido contactos de emergencia. + + + Nombre + + + Parentesco + + + Número de teléfono + + + Número de teléfono alternativo + + + Correo electrónico + + + Notas + + + Contacto principal + + + Principal + + + Alternativo + + + + + + Editar + + + Eliminar + + + Guardar + + + Cancelar + + + Se requiere un nombre. + + + No se pudo guardar el contacto. + + + ¿Eliminar este contacto de emergencia? + + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.fr.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.fr.resx new file mode 100644 index 000000000..27564161e --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.fr.resx @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Protection avancée des données + + + Accueil + + + Sécurité + + + Fenêtre de migration active. + + + La saisie de données est suspendue jusqu'à la fermeture de la fenêtre ; la consultation n'est pas affectée. + + + État de la protection + + + État actuel : + + + L'inscription à la protection avancée des données est temporairement indisponible. Si votre département a déjà acheté le module, il reste valide — revenez bientôt. + + + La protection avancée des données chiffre les données sensibles de répartition, de personnel et de contacts de votre département avec des clés lui appartenant. Elle nécessite le module ADP annuel. + + + Acheter le module ADP + + + Seul le membre gestionnaire de votre département peut acheter le module et procéder à l'inscription. + + + Le module ADP est actif. Seul le membre gestionnaire de votre département peut finaliser l'inscription. + + + L'inscription est en file d'attente. La migration s'exécute pendant la fenêtre nocturne choisie ({0}–{1}, {2}). Vous recevrez un e-mail à l'ouverture et à la fermeture de la fenêtre chaque nuit. + + + Annuler l'inscription en file d'attente + + + La migration est en cours. Le travail n'a lieu que dans votre fenêtre nocturne ({0}–{1}, {2}) ; en dehors, votre département est pleinement opérationnel. + + + La protection avancée des données est active. + + + Les champs protégés sont chiffrés avec les clés de votre département ; leur consultation exige un membre connecté avec une vérification récente de l'authentificateur. + + + Pour désactiver ADP, annulez le module sur la page d'abonnement. La protection reste active jusqu'à la fin de votre période de facturation en cours. Une fois vos données déchiffrées, la réactivation exige de racheter le module et de refaire une inscription. + + + page d'abonnement + + + La désactivation est planifiée. + + + La protection reste pleinement active jusqu'au {0}, date à laquelle vos données sont déchiffrées vers le stockage standard sur une ou plusieurs fenêtres nocturnes. + + + la fin de votre période de facturation + + + Conserver la protection avancée des données (annuler la désactivation) + + + La désactivation est en cours. La protection reste en vigueur jusqu'à ce que vos données soient entièrement restaurées vers le stockage standard. + + + La dernière migration n'a pas pu s'achever et reprendra après examen. Votre département est pleinement opérationnel et vos données restent en sécurité. Le support a été alerté ; aucune action n'est nécessaire. + + + Assistant d'inscription + + + Continuer + + + Retour + + + Étape 1 sur 6 — Ce que couvre la protection avancée des données + + + ADP chiffre le contenu sensible des données de votre département avec des clés lui appartenant, conservées dans un service de clés renforcé : + + + Protégé : + + + noms, natures, notes et adresses d'appels ; notes et pièces jointes d'appels ; coordonnées et notes de contacts ; données sensibles des membres. + + + Reste en clair : + + + identifiants système, numéros d'appel, priorités, statuts, horodatages, noms d'unités et structure du département — la plateforme en a besoin pour acheminer et afficher le travail. + + + Pendant la protection : + + + La recherche, les rapports, les exports et les intégrations tierces ne voient pas le contenu protégé. + + + Les écrans Big Board affichent une coque réduite « incident protégé » au lieu des détails de l'appel. + + + Les workflows reçoivent des charges expurgées (les valeurs protégées sont remplacées par REDACTED). + + + Les notifications SMS, e-mail, push et vocales envoient par défaut un contenu générique (« Une répartition protégée est disponible — connectez-vous à Resgrid »). + + + La protection avancée des données ne constitue pas à elle seule une conformité HIPAA ou ePCR. C'est un contrôle au sein d'un programme de conformité qui reste sous la responsabilité de votre organisme. + + + Étape 2 sur 6 — Reconnaissances + + + Chaque point ci-dessous doit être reconnu par le membre gestionnaire. Vos reconnaissances sont enregistrées avec un horodatage de version. + + + Je comprends quelles familles de données sont protégées (appels, notes/pièces jointes d'appels, contacts, données sensibles des membres) et que le catalogue des champs protégés est versionné. + + + Je comprends que les métadonnées système (numéros d'appel, priorités, statuts, horodatages, structure des unités et du département) restent en clair. + + + Je comprends que les serveurs de Resgrid traitent les valeurs protégées en mémoire lorsqu'un membre autorisé les consulte ou les modifie. + + + Je comprends que les membres ont besoin d'une vérification récente de l'authentificateur (MFA) pour consulter les valeurs protégées, dans une fenêtre configurée par mon département (15 minutes par défaut ; au-delà de 60, un motif enregistré est requis). + + + Je comprends que les écrans Big Board affichent des coques réduites pour les incidents protégés. + + + Je comprends que les workflows reçoivent des charges expurgées et ne peuvent pas agir sur le contenu protégé. + + + Je comprends que les notifications SMS/e-mail/push/vocales envoient par défaut un contenu générique, et qu'assouplir un canal exige une modification de politique reconnue séparément. + + + Je comprends que la recherche, les rapports, les exports, les intégrations et l’accès hors ligne ne voient pas le contenu protégé. + + + Je comprends que l'activation lance une migration nocturne et que la désactivation ultérieure déchiffre mes données sur des fenêtres nocturnes à la fin de la période de facturation. + + + Je comprends que le support Resgrid ne peut pas lire les valeurs protégées sans une autorisation de support explicite, auditée et approuvée par le département, et que la perte de clés n'est récupérable que par le processus documenté. + + + Je comprends que la protection avancée des données ne constitue pas à elle seule une conformité HIPAA/ePCR. + + + Étape 3 sur 6 — Vérifications préalables + + + Membre gestionnaire + + + Forfait payant + + + Module ADP actif + + + Disponibilité de l'inscription (contrôle plateforme) + + + L'état du département autorise l'inscription + + + Service de protection joignable + + + Le membre gestionnaire a un authentificateur enregistré + + + Une vérification préalable a échoué. Corrigez les éléments marqués ✖ et rechargez cette page — l'inscription ne peut être mise en file d'attente tant que toutes les vérifications ne passent pas. + + + Étape 4 sur 6 — Estimation de la taille de la migration + + + Une analyse en lecture seule compte les lignes de votre département et estime le nombre de fenêtres nocturnes nécessaires. Cette analyse ne modifie rien. + + + Lancer l'analyse de taille + + + Étape 5 sur 6 — Fenêtre de migration nocturne + + + La migration ne s'exécute que dans cette fenêtre locale au département et suspend la saisie pendant son exécution. Choisissez vos heures les plus calmes ; la valeur par défaut est {0}–{1}. + + + Début de la fenêtre (local) + + + Fin de la fenêtre (local) + + + Fuseau horaire + + + Pause des opérations du département. + + + Pendant l'exécution d'une fenêtre de migration, aucun nouvel appel, changement de statut, changement d'effectif ou tâche planifiée n'est traité pour votre département. La consultation continue. Vous pouvez interrompre une fenêtre active à tout moment et le département revient immédiatement en service complet. + + + Je comprends et consens à la pause nocturne des opérations pendant les fenêtres de migration. + + + Étape 6 sur 6 — Confirmer et mettre en file d'attente + + + La mise en file d'attente revérifie chaque exigence côté serveur et place votre département dans la file de migration. Les départements migrent un par un ; vous recevrez un e-mail avec votre planning. + + + Mettre l'inscription en file d'attente + + + Toutes les reconnaissances doivent être cochées avant de mettre l'inscription en file d'attente. + + + La pause nocturne des opérations doit être acceptée avant de mettre l'inscription en file d'attente. + + + Seul le membre gestionnaire du département peut exécuter cette commande. + + + Un module de protection avancée des données actif est requis. + + + La protection avancée des données nécessite un forfait payant. + + + L'inscription à la protection avancée des données est temporairement indisponible. + + + L'état de protection du département n'autorise pas cette commande. Rechargez la page pour l'état actuel. + + + Un fuseau horaire valide pour la fenêtre de migration est requis. + + + La commande n’a pas pu être exécutée ; elle peut être réessayée. + + + Annuler l'inscription en file d'attente ? Rien n'a encore été migré ; vous pourrez vous réinscrire plus tard tant que le module est actif. + + + Conserver la protection avancée des données active ? La désactivation planifiée sera annulée. + + + Contacts d'urgence + + + Les contacts d'urgence sont propres à ce département et sont stockés selon ses paramètres de protection des données. Vous pouvez en ajouter plusieurs. + + + Ajouter un contact d'urgence + + + Modifier le contact d'urgence + + + Contact d'urgence + + + Aucun contact d'urgence n'a été ajouté. + + + Nom + + + Lien + + + Numéro de téléphone + + + Numéro de téléphone secondaire + + + E-mail + + + Notes + + + Contact principal + + + Principal + + + Secondaire + + + Oui + + + Modifier + + + Supprimer + + + Enregistrer + + + Annuler + + + Un nom est requis. + + + Le contact n'a pas pu être enregistré. + + + Supprimer ce contact d'urgence ? + + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.it.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.it.resx new file mode 100644 index 000000000..790ab853a --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.it.resx @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Protezione avanzata dei dati + + + Home + + + Sicurezza + + + Finestra di migrazione attiva. + + + L'inserimento dati è in pausa fino alla chiusura della finestra; la consultazione non è interessata. + + + Stato della protezione + + + Stato attuale: + + + L'iscrizione alla protezione avanzata dei dati non è temporaneamente disponibile. Se il tuo dipartimento ha già acquistato il componente aggiuntivo, resta valido: riprova a breve. + + + La protezione avanzata dei dati cifra i dati sensibili di dispatch, personale e contatti del tuo dipartimento con chiavi di proprietà del dipartimento. Richiede il componente aggiuntivo ADP annuale. + + + Acquista il componente aggiuntivo ADP + + + Solo il membro amministratore del tuo dipartimento può acquistare il componente aggiuntivo e iscriversi. + + + Il componente aggiuntivo ADP è attivo. Solo il membro amministratore del tuo dipartimento può completare l'iscrizione. + + + L'iscrizione è in coda. La migrazione viene eseguita nella finestra notturna selezionata ({0}–{1}, {2}). Riceverai un'email all'apertura e alla chiusura della finestra ogni notte. + + + Annulla l'iscrizione in coda + + + La migrazione è in corso. Il lavoro avviene solo nella finestra notturna ({0}–{1}, {2}); al di fuori il tuo dipartimento è in pieno servizio. + + + La protezione avanzata dei dati è attiva. + + + I campi protetti sono cifrati con le chiavi del tuo dipartimento; per visualizzarli serve un membro autenticato con una verifica recente dell'app di autenticazione. + + + Per disattivare ADP, annulla il componente aggiuntivo nella pagina dell'abbonamento. La protezione resta attiva fino alla fine del periodo di fatturazione corrente. Dopo la decifratura dei dati, riattivarla richiede di riacquistare il componente e completare una nuova iscrizione. + + + pagina dell'abbonamento + + + La disattivazione è pianificata. + + + La protezione resta pienamente attiva fino al {0}, quando i tuoi dati vengono decifrati e riportati all'archiviazione standard in una o più finestre notturne. + + + la fine del periodo di fatturazione + + + Mantieni la protezione avanzata dei dati (revoca la disattivazione) + + + La disattivazione è in corso. La protezione resta in vigore finché i tuoi dati non sono completamente ripristinati nell'archiviazione standard. + + + L'ultima esecuzione della migrazione non è stata completata e riprenderà dopo una verifica. Il tuo dipartimento è in pieno servizio e i dati restano al sicuro. Il supporto è stato avvisato; non serve alcuna azione. + + + Procedura guidata di iscrizione + + + Continua + + + Indietro + + + Passaggio 1 di 6 — Cosa copre la protezione avanzata dei dati + + + ADP cifra i contenuti sensibili dei dati del tuo dipartimento con chiavi di sua proprietà, custodite in un servizio chiavi rafforzato: + + + Protetto: + + + nomi, nature, note e indirizzi delle chiamate; note e allegati delle chiamate; dati e note dei contatti; dati sensibili dei membri. + + + Resta in chiaro: + + + identificatori di sistema, numeri di chiamata, priorità, stati, marche temporali, nomi delle unità e struttura del dipartimento: la piattaforma ne ha bisogno per instradare e mostrare il lavoro. + + + Mentre è protetto: + + + Ricerca, report, esportazioni e integrazioni di terze parti non possono vedere i contenuti protetti. + + + I display Big Board mostrano un guscio ridotto «incidente protetto» invece dei dettagli della chiamata. + + + I flussi di lavoro ricevono payload oscurati (i valori protetti sono sostituiti con REDACTED). + + + Le notifiche SMS, email, push e vocali inviano per impostazione predefinita contenuti generici («È disponibile un dispatch protetto — accedi a Resgrid»). + + + La protezione avanzata dei dati da sola non costituisce conformità HIPAA o ePCR. È un controllo all'interno di un programma di conformità che resta di competenza del tuo ente. + + + Passaggio 2 di 6 — Presa d’atto + + + Ogni voce sottostante deve essere accettata dal membro amministratore. Le accettazioni vengono registrate con un timbro di versione. + + + Comprendo quali famiglie di dati sono protette (chiamate, note/allegati delle chiamate, contatti, dati sensibili dei membri) e che il catalogo dei campi protetti è versionato. + + + Comprendo che i metadati di sistema (numeri di chiamata, priorità, stati, marche temporali, struttura di unità e dipartimento) restano in chiaro. + + + Comprendo che i server di Resgrid elaborano i valori protetti in memoria quando un membro autorizzato li visualizza o modifica. + + + Comprendo che i membri necessitano di una verifica recente dell'autenticatore (MFA) per visualizzare i valori protetti, entro una finestra configurata dal mio dipartimento (predefinita 15 minuti; oltre 60 serve una motivazione registrata). + + + Comprendo che i display Big Board mostrano gusci ridotti per gli incidenti protetti. + + + Comprendo che i flussi di lavoro ricevono payload oscurati e non possono agire sui contenuti protetti. + + + Comprendo che le notifiche SMS/email/push/vocali inviano contenuti generici per impostazione predefinita e che allentare un canale richiede una modifica di policy accettata separatamente. + + + Comprendo che ricerca, report, esportazioni, integrazioni e accesso offline non possono vedere i contenuti protetti. + + + Comprendo che l'attivazione avvia una migrazione notturna e che la disattivazione successiva decifra i miei dati in finestre notturne alla fine del periodo di fatturazione. + + + Comprendo che il supporto Resgrid non può leggere valori protetti senza una concessione di supporto esplicita, verificata e approvata dal dipartimento, e che la perdita delle chiavi è recuperabile solo tramite il processo documentato. + + + Comprendo che la protezione avanzata dei dati da sola non è conformità HIPAA/ePCR. + + + Passaggio 3 di 6 — Controlli preliminari + + + Membro amministratore + + + Piano a pagamento + + + Componente aggiuntivo ADP attivo + + + Disponibilità di iscrizione (gate della piattaforma) + + + Lo stato del dipartimento consente l’iscrizione + + + Servizio di protezione raggiungibile + + + Il membro amministratore ha un autenticatore registrato + + + Un controllo preliminare non è riuscito. Risolvi le voci contrassegnate con ✖ e ricarica la pagina: l’iscrizione non può essere accodata finché tutti i controlli non passano. + + + Passaggio 4 di 6 — Stima delle dimensioni della migrazione + + + Una scansione in sola lettura conta le righe del tuo dipartimento e stima quante finestre notturne servono. Questa scansione non modifica nulla. + + + Esegui scansione dimensioni + + + Passaggio 5 di 6 — Finestra di migrazione notturna + + + La migrazione viene eseguita solo in questa finestra locale del dipartimento e mette in pausa l'inserimento dati. Scegli le ore più tranquille; il valore predefinito è {0}–{1}. + + + Inizio finestra (locale) + + + Fine finestra (locale) + + + Fuso orario + + + Pausa delle operazioni del dipartimento. + + + Mentre una finestra di migrazione è in esecuzione, per il tuo dipartimento non vengono elaborate nuove chiamate, modifiche di stato, modifiche di organico o attività pianificate. La consultazione continua. Puoi interrompere una finestra attiva in qualsiasi momento e il dipartimento torna subito in pieno servizio. + + + Comprendo e acconsento alla pausa notturna delle operazioni durante le finestre di migrazione. + + + Passaggio 6 di 6 — Conferma e accoda + + + L'accodamento verifica nuovamente ogni requisito lato server e inserisce il tuo dipartimento nella coda di migrazione. I dipartimenti migrano uno alla volta; riceverai un'email con la pianificazione. + + + Accoda iscrizione + + + Tutte le accettazioni devono essere selezionate prima di accodare l'iscrizione. + + + È necessario acconsentire alla pausa notturna delle operazioni prima di accodare l'iscrizione. + + + Solo il membro amministratore del dipartimento può eseguire questo comando. + + + È richiesto un componente aggiuntivo di protezione avanzata dei dati attivo. + + + La protezione avanzata dei dati richiede un piano a pagamento. + + + L'iscrizione alla protezione avanzata dei dati non è temporaneamente disponibile. + + + Lo stato di protezione del dipartimento non consente questo comando. Ricarica la pagina per lo stato attuale. + + + È richiesto un fuso orario valido per la finestra di migrazione. + + + Impossibile completare il comando; è possibile riprovare. + + + Annullare l'iscrizione in coda? Non è stato ancora migrato nulla; potrai iscriverti di nuovo in seguito finché il componente aggiuntivo è attivo. + + + Mantenere attiva la protezione avanzata dei dati? La disattivazione pianificata verrà annullata. + + + Contatti di emergenza + + + I contatti di emergenza sono specifici di questo dipartimento e vengono archiviati con le impostazioni di protezione dei dati del dipartimento. Puoi aggiungerne più di uno. + + + Aggiungi contatto di emergenza + + + Modifica contatto di emergenza + + + Contatto di emergenza + + + Non è stato aggiunto alcun contatto di emergenza. + + + Nome + + + Relazione + + + Numero di telefono + + + Numero di telefono alternativo + + + Email + + + Note + + + Contatto principale + + + Principale + + + Alternativo + + + + + + Modifica + + + Elimina + + + Salva + + + Annulla + + + È richiesto un nome. + + + Impossibile salvare il contatto. + + + Rimuovere questo contatto di emergenza? + + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.pl.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.pl.resx new file mode 100644 index 000000000..f620cb927 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.pl.resx @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Zaawansowana ochrona danych + + + Strona główna + + + Zabezpieczenia + + + Okno migracji aktywne. + + + Wprowadzanie danych jest wstrzymane do zamknięcia okna; przeglądanie działa normalnie. + + + Stan ochrony + + + Bieżący stan: + + + Rejestracja w zaawansowanej ochronie danych jest chwilowo niedostępna. Jeśli Twój departament już kupił dodatek, pozostaje on ważny — sprawdź ponownie wkrótce. + + + Zaawansowana ochrona danych szyfruje wrażliwe dane dysponowania, personelu i kontaktów Twojego departamentu kluczami należącymi do departamentu. Wymaga rocznego dodatku ADP. + + + Kup dodatek ADP + + + Tylko członek zarządzający Twoim departamentem może kupić dodatek i dokonać rejestracji. + + + Dodatek ADP jest aktywny. Tylko członek zarządzający Twoim departamentem może dokończyć rejestrację. + + + Rejestracja jest w kolejce. Migracja działa w wybranym oknie nocnym ({0}–{1}, {2}). Otrzymasz e-mail, gdy okno każdej nocy się otworzy i zamknie. + + + Anuluj rejestrację w kolejce + + + Migracja jest w toku. Prace odbywają się tylko w oknie nocnym ({0}–{1}, {2}); poza nim departament działa w pełni. + + + Zaawansowana ochrona danych jest aktywna. + + + Chronione pola są szyfrowane kluczami Twojego departamentu; ich wyświetlenie wymaga zalogowanego członka ze świeżą weryfikacją aplikacji uwierzytelniającej. + + + Aby wyłączyć ADP, anuluj dodatek na stronie subskrypcji. Ochrona pozostaje aktywna do końca bieżącego okresu rozliczeniowego. Po odszyfrowaniu danych ponowne włączenie wymaga ponownego zakupu dodatku i nowej rejestracji. + + + strona subskrypcji + + + Wyłączenie jest zaplanowane. + + + Ochrona pozostaje w pełni aktywna do {0}, kiedy Twoje dane zostaną odszyfrowane do standardowego magazynu w jednym lub kilku oknach nocnych. + + + koniec okresu rozliczeniowego + + + Zachowaj zaawansowaną ochronę danych (cofnij wyłączenie) + + + Wyłączanie jest w toku. Ochrona obowiązuje, dopóki Twoje dane nie zostaną w pełni przywrócone do standardowego magazynu. + + + Ostatnia migracja nie mogła się zakończyć i zostanie wznowiona po przeglądzie. Twój departament działa w pełni, a dane pozostają bezpieczne. Powiadomiono wsparcie; nie są potrzebne żadne działania. + + + Kreator rejestracji + + + Dalej + + + Wstecz + + + Krok 1 z 6 — co obejmuje zaawansowana ochrona danych + + + ADP szyfruje wrażliwą treść danych Twojego departamentu kluczami należącymi do departamentu, przechowywanymi w utwardzonej usłudze kluczy: + + + Chronione: + + + nazwy, charaktery, notatki i adresy zgłoszeń; notatki i załączniki zgłoszeń; dane i notatki kontaktów; wrażliwe dane członków. + + + Pozostaje jawne: + + + identyfikatory systemowe, numery zgłoszeń, priorytety, statusy, znaczniki czasu, nazwy jednostek i struktura departamentu — platforma potrzebuje ich do kierowania i wyświetlania pracy. + + + Gdy ochrona jest włączona: + + + Wyszukiwanie, raporty, eksporty i integracje zewnętrzne nie widzą chronionej treści. + + + Ekrany Big Board pokazują uproszczoną informację „zdarzenie chronione” zamiast szczegółów zgłoszenia. + + + Przepływy pracy otrzymują zredagowane dane (chronione wartości zastąpione przez REDACTED). + + + Powiadomienia SMS, e-mail, push i głosowe domyślnie wysyłają treść ogólną („Dostępne jest chronione zgłoszenie — zaloguj się do Resgrid”). + + + Sama zaawansowana ochrona danych nie oznacza zgodności z HIPAA ani ePCR. To jeden element programu zgodności, za który nadal odpowiada Twoja organizacja. + + + Krok 2 z 6 — potwierdzenia + + + Każdy poniższy punkt musi zostać potwierdzony przez członka zarządzającego. Potwierdzenia są zapisywane ze znacznikiem wersji. + + + Rozumiem, które rodziny danych są chronione (zgłoszenia, notatki/załączniki zgłoszeń, kontakty, wrażliwe dane członków) i że katalog chronionych pól jest wersjonowany. + + + Rozumiem, że metadane systemowe (numery zgłoszeń, priorytety, statusy, znaczniki czasu, struktura jednostek i departamentu) pozostają jawne. + + + Rozumiem, że serwery Resgrid przetwarzają chronione wartości w pamięci, gdy uprawniony członek je wyświetla lub edytuje. + + + Rozumiem, że członkowie potrzebują świeżej weryfikacji uwierzytelniacza (MFA), aby zobaczyć chronione wartości, w oknie konfigurowanym przez mój departament (domyślnie 15 minut; wartości powyżej 60 wymagają zapisanego uzasadnienia). + + + Rozumiem, że ekrany Big Board pokazują uproszczone informacje o chronionych zdarzeniach. + + + Rozumiem, że przepływy pracy otrzymują zredagowane dane i nie mogą działać na chronionej treści. + + + Rozumiem, że powiadomienia SMS/e-mail/push/głosowe domyślnie wysyłają treść ogólną, a poluzowanie kanału wymaga odrębnie potwierdzonej zmiany zasad. + + + Rozumiem, że wyszukiwanie, raporty, eksporty, integracje i dostęp offline nie widzą chronionej treści. + + + Rozumiem, że włączenie uruchamia nocną migrację, a późniejsze wyłączenie odszyfrowuje moje dane w oknach nocnych na koniec okresu rozliczeniowego. + + + Rozumiem, że wsparcie Resgrid nie może odczytać chronionych wartości bez wyraźnego, audytowanego i zatwierdzonego przez departament zezwolenia, a utrata kluczy jest odwracalna tylko przez udokumentowany proces odzyskiwania. + + + Rozumiem, że sama zaawansowana ochrona danych nie oznacza zgodności z HIPAA/ePCR. + + + Krok 3 z 6 — kontrole wstępne + + + Członek zarządzający + + + Płatny plan + + + Aktywny dodatek ADP + + + Dostępność rejestracji (bramka platformy) + + + Stan departamentu zezwala na rejestrację + + + Usługa ochrony osiągalna + + + Członek zarządzający ma skonfigurowany uwierzytelniacz + + + Kontrola wstępna nie powiodła się. Rozwiąż pozycje oznaczone ✖ i odśwież stronę — rejestracji nie można zakolejkować, dopóki wszystkie kontrole nie przejdą. + + + Krok 4 z 6 — szacunek rozmiaru migracji + + + Skan tylko do odczytu liczy wiersze Twojego departamentu i szacuje, ile okien nocnych potrzebuje migracja. Ten skan niczego nie zmienia. + + + Uruchom skan rozmiaru + + + Krok 5 z 6 — nocne okno migracji + + + Migracja działa tylko w tym lokalnym oknie departamentu i wstrzymuje wprowadzanie danych. Wybierz najspokojniejsze godziny; domyślnie {0}–{1}. + + + Początek okna (lokalnie) + + + Koniec okna (lokalnie) + + + Strefa czasowa + + + Wstrzymanie działania departamentu. + + + Gdy trwa okno migracji, dla Twojego departamentu nie są realizowane nowe zgłoszenia, zmiany statusu, zmiany obsady ani zadania zaplanowane. Przeglądanie działa. Aktywne okno możesz przerwać w dowolnej chwili, a departament natychmiast wraca do pełnej służby. + + + Rozumiem i zgadzam się na nocne wstrzymanie działania podczas okien migracji. + + + Krok 6 z 6 — potwierdź i zakolejkuj + + + Zakolejkowanie rejestracji ponownie weryfikuje każdy wymóg po stronie serwera i umieszcza departament w kolejce migracji. Departamenty migrują pojedynczo; otrzymasz e-mail z harmonogramem. + + + Zakolejkuj rejestrację + + + Przed zakolejkowaniem rejestracji należy zaznaczyć wszystkie potwierdzenia. + + + Przed zakolejkowaniem rejestracji trzeba wyrazić zgodę na nocne wstrzymanie działania. + + + Tylko członek zarządzający departamentem może wykonać to polecenie. + + + Wymagany jest aktywny dodatek zaawansowanej ochrony danych. + + + Zaawansowana ochrona danych wymaga płatnego planu. + + + Rejestracja w zaawansowanej ochronie danych jest chwilowo niedostępna. + + + Stan ochrony departamentu nie zezwala na to polecenie. Odśwież stronę, aby zobaczyć bieżący status. + + + Wymagana jest prawidłowa strefa czasowa okna migracji. + + + Nie udało się wykonać polecenia; można spróbować ponownie. + + + Anulować rejestrację w kolejce? Nic jeszcze nie zostało zmigrowane; możesz zarejestrować się ponownie później, gdy dodatek jest aktywny. + + + Zachować aktywną zaawansowaną ochronę danych? Zaplanowane wyłączenie zostanie anulowane. + + + Kontakty alarmowe + + + Kontakty alarmowe dotyczą tego departamentu i są przechowywane zgodnie z jego ustawieniami ochrony danych. Możesz dodać więcej niż jeden. + + + Dodaj kontakt alarmowy + + + Edytuj kontakt alarmowy + + + Kontakt alarmowy + + + Nie dodano kontaktów alarmowych. + + + Imię i nazwisko + + + Pokrewieństwo + + + Numer telefonu + + + Alternatywny numer telefonu + + + E-mail + + + Notatki + + + Główny kontakt + + + Główny + + + Alternatywny + + + Tak + + + Edytuj + + + Usuń + + + Zapisz + + + Anuluj + + + Wymagane jest imię i nazwisko. + + + Nie udało się zapisać kontaktu. + + + Usunąć ten kontakt alarmowy? + + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.sv.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.sv.resx new file mode 100644 index 000000000..850d357b5 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.sv.resx @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Avancerat dataskydd + + + Hem + + + Säkerhet + + + Migreringsfönstret är aktivt. + + + Datainmatning är pausad tills fönstret stängs; visning påverkas inte. + + + Skyddsstatus + + + Aktuellt tillstånd: + + + Registrering för avancerat dataskydd är tillfälligt otillgänglig. Om din organisation redan har köpt tillägget är det fortfarande giltigt — återkom snart. + + + Avancerat dataskydd krypterar organisationens känsliga larm-, personal- och kontaktuppgifter med nycklar som organisationen äger. Det kräver det årliga ADP-tillägget. + + + Köp ADP-tillägget + + + Endast organisationens ansvariga medlem kan köpa tillägget och registrera. + + + ADP-tillägget är aktivt. Endast organisationens ansvariga medlem kan slutföra registreringen. + + + Registreringen är köad. Migreringen körs under det valda nattfönstret ({0}–{1}, {2}). Du får ett e-postmeddelande när varje natts fönster öppnas och stängs. + + + Avbryt köad registrering + + + Migreringen pågår. Arbetet sker endast inom nattfönstret ({0}–{1}, {2}); utanför det är organisationen i full drift. + + + Avancerat dataskydd är aktivt. + + + Skyddade fält krypteras med organisationens nycklar; för att visa dem krävs en inloggad medlem med en färsk autentiseringsverifiering. + + + För att stänga av ADP, avbryt tillägget på prenumerationssidan. Skyddet är aktivt till slutet av innevarande faktureringsperiod. När dina uppgifter har dekrypterats krävs ett nytt köp av tillägget och en ny registrering för att slå på det igen. + + + prenumerationssidan + + + Avregistrering är schemalagd. + + + Skyddet är fullt aktivt till {0}, då dina uppgifter dekrypteras tillbaka till standardlagring under ett eller flera nattfönster. + + + slutet av din faktureringsperiod + + + Behåll avancerat dataskydd (återkalla avregistrering) + + + Avregistreringen pågår. Skyddet gäller tills dina uppgifter är helt återställda till standardlagring. + + + Den senaste migreringen kunde inte slutföras och återupptas efter granskning. Din organisation är i full drift och dina uppgifter är säkra. Supporten är underrättad; ingen åtgärd behövs. + + + Registreringsguide + + + Fortsätt + + + Tillbaka + + + Steg 1 av 6 — Vad avancerat dataskydd omfattar + + + ADP krypterar det känsliga innehållet i organisationens data med nycklar som organisationen äger och som förvaras i en härdad nyckeltjänst: + + + Skyddat: + + + larmnamn, larmtyper, anteckningar och adresser; larmanteckningar och bilagor; kontaktuppgifter och anteckningar; medlemmars känsliga uppgifter. + + + Förblir i klartext: + + + systemidentifierare, larmnummer, prioriteter, statusar, tidsstämplar, enhetsnamn och organisationsstruktur — plattformen behöver dessa för att dirigera och visa arbetet. + + + Medan skyddet är aktivt: + + + Sökning, rapporter, exporter och tredjepartsintegrationer kan inte se skyddat innehåll. + + + Big Board-skärmar visar ett reducerat skal med ”skyddad händelse” i stället för larmdetaljer. + + + Arbetsflöden får maskerade nyttolaster (skyddade värden ersätts med REDACTED). + + + Sms-, e-post-, push- och röstnotiser skickar som standard generiskt innehåll (”Ett skyddat larm finns tillgängligt — logga in på Resgrid”). + + + Avancerat dataskydd innebär i sig inte HIPAA- eller ePCR-efterlevnad. Det är en kontroll inom ett efterlevnadsprogram som din organisation fortfarande ansvarar för. + + + Steg 2 av 6 — Bekräftelser + + + Varje punkt nedan måste bekräftas av den ansvariga medlemmen. Dina bekräftelser registreras med en versionsstämpel. + + + Jag förstår vilka datafamiljer som skyddas (larm, larmanteckningar/bilagor, kontakter, medlemmars känsliga uppgifter) och att katalogen över skyddade fält är versionshanterad. + + + Jag förstår att systemmetadata (larmnummer, prioriteter, statusar, tidsstämplar, enhets- och organisationsstruktur) förblir i klartext. + + + Jag förstår att Resgrids servrar behandlar skyddade värden i minnet när en behörig medlem visar eller redigerar dem. + + + Jag förstår att medlemmar behöver en färsk autentiseringsverifiering (MFA) för att visa skyddade värden, inom ett fönster som min organisation konfigurerar (standard 15 minuter; värden över 60 kräver en registrerad motivering). + + + Jag förstår att Big Board-skärmar visar reducerade skal för skyddade händelser. + + + Jag förstår att arbetsflöden får maskerade nyttolaster och inte kan agera på skyddat innehåll. + + + Jag förstår att sms-/e-post-/push-/röstnotiser skickar generiskt innehåll som standard, och att lätta på en kanal kräver en separat bekräftad policyändring. + + + Jag förstår att sökning, rapporter, exporter, integrationer och offlineåtkomst inte kan se skyddat innehåll. + + + Jag förstår att aktivering kör en nattlig migrering och att senare avaktivering dekrypterar mina uppgifter under nattfönster vid faktureringsperiodens slut. + + + Jag förstår att Resgrids support inte kan läsa skyddade värden utan ett uttryckligt, granskat och organisationsgodkänt supportmedgivande, och att nyckelförlust endast kan återställas via den dokumenterade återställningsprocessen. + + + Jag förstår att avancerat dataskydd i sig inte innebär HIPAA/ePCR-efterlevnad. + + + Steg 3 av 6 — Förkontroller + + + Ansvarig medlem + + + Betald plan + + + Aktivt ADP-tillägg + + + Registreringstillgänglighet (plattformsspärr) + + + Organisationens tillstånd tillåter registrering + + + Skyddstjänsten är nåbar + + + Ansvarig medlem har en autentiseringsapp registrerad + + + En förkontroll misslyckades. Åtgärda punkterna märkta ✖ och läs om sidan — registreringen kan inte köas förrän alla kontroller godkänns. + + + Steg 4 av 6 — Uppskattning av migreringens storlek + + + En skrivskyddad genomsökning räknar organisationens rader och uppskattar hur många nattfönster migreringen behöver. Genomsökningen ändrar ingenting. + + + Kör storleksgenomsökning + + + Steg 5 av 6 — Nattligt migreringsfönster + + + Migreringen körs endast inom detta organisationslokala fönster och pausar datainmatning under tiden. Välj era lugnaste timmar; standard är {0}–{1}. + + + Fönstrets start (lokal tid) + + + Fönstrets slut (lokal tid) + + + Tidszon + + + Paus i organisationens drift. + + + Medan ett migreringsfönster körs behandlas inga nya larm, statusändringar, bemanningsändringar eller schemalagda uppgifter för din organisation. Visning fortsätter. Du kan avbryta ett aktivt fönster när som helst och organisationen återgår omedelbart till full drift. + + + Jag förstår och samtycker till den nattliga driftpausen under migreringsfönstren. + + + Steg 6 av 6 — Bekräfta och köa + + + Att köa registreringen verifierar alla krav på serversidan igen och placerar din organisation i migreringskön. Organisationer migrerar en i taget; du får ett e-postmeddelande med ditt schema. + + + Köa registrering + + + Alla bekräftelser måste kryssas i innan registreringen kan köas. + + + Den nattliga driftpausen måste godkännas innan registreringen kan köas. + + + Endast organisationens ansvariga medlem får köra detta kommando. + + + Ett aktivt tillägg för avancerat dataskydd krävs. + + + Avancerat dataskydd kräver en betald plan. + + + Registrering för avancerat dataskydd är tillfälligt otillgänglig. + + + Organisationens skyddstillstånd tillåter inte detta kommando. Läs om sidan för aktuell status. + + + En giltig tidszon för migreringsfönstret krävs. + + + Kommandot kunde inte slutföras; det kan försökas igen. + + + Avbryta den köade registreringen? Inget har migrerats ännu; du kan registrera dig igen senare medan tillägget är aktivt. + + + Behålla avancerat dataskydd aktivt? Den schemalagda avregistreringen avbryts. + + + Nödkontakter + + + Nödkontakter är specifika för denna organisation och lagras enligt organisationens dataskyddsinställningar. Du kan lägga till fler än en. + + + Lägg till nödkontakt + + + Redigera nödkontakt + + + Nödkontakt + + + Inga nödkontakter har lagts till. + + + Namn + + + Relation + + + Telefonnummer + + + Alternativt telefonnummer + + + E-post + + + Anteckningar + + + Primär kontakt + + + Primär + + + Alternativ + + + Ja + + + Redigera + + + Ta bort + + + Spara + + + Avbryt + + + Ett namn krävs. + + + Kontakten kunde inte sparas. + + + Ta bort denna nödkontakt? + + diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.uk.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.uk.resx new file mode 100644 index 000000000..bc75d8e96 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.uk.resx @@ -0,0 +1,391 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Розширений захист даних + + + Головна + + + Безпека + + + Вікно міграції активне. + + + Введення даних призупинено до закриття вікна; перегляд не зачіпається. + + + Стан захисту + + + Поточний стан: + + + Реєстрація розширеного захисту даних тимчасово недоступна. Якщо ваш підрозділ уже придбав доповнення, воно залишається дійсним — завітайте пізніше. + + + Розширений захист даних шифрує чутливі диспетчерські, кадрові та контактні дані вашого підрозділу ключами, які належать підрозділу. Потрібне річне доповнення ADP. + + + Придбати доповнення ADP + + + Лише керівний учасник вашого підрозділу може придбати доповнення та зареєструвати. + + + Доповнення ADP активне. Лише керівний учасник вашого підрозділу може завершити реєстрацію. + + + Реєстрацію поставлено в чергу. Міграція виконується у вибраному нічному вікні ({0}–{1}, {2}). Ви отримаєте лист, коли вікно кожної ночі відкриється та закриється. + + + Скасувати реєстрацію в черзі + + + Міграція триває. Робота відбувається лише у нічному вікні ({0}–{1}, {2}); поза ним підрозділ працює у повному обсязі. + + + Розширений захист даних активний. + + + Захищені поля шифруються ключами вашого підрозділу; для перегляду потрібен авторизований учасник зі свіжим підтвердженням автентифікатора. + + + Щоб вимкнути ADP, скасуйте доповнення на сторінці передплати. Захист залишається активним до кінця поточного платіжного періоду. Після розшифрування даних повторне ввімкнення потребує повторної купівлі доповнення та нової реєстрації. + + + сторінка передплати + + + Відключення заплановано. + + + Захист залишається повністю активним до {0}, коли ваші дані буде розшифровано назад у стандартне сховище протягом одного або кількох нічних вікон. + + + кінця вашого платіжного періоду + + + Зберегти розширений захист даних (скасувати відключення) + + + Відключення триває. Захист лишається чинним, доки ваші дані не буде повністю відновлено у стандартному сховищі. + + + Останній запуск міграції не вдалося завершити; його буде відновлено після перевірки. Ваш підрозділ працює повністю, дані у безпеці. Підтримку сповіщено; жодних дій не потрібно. + + + Майстер реєстрації + + + Продовжити + + + Назад + + + Крок 1 з 6 — що охоплює розширений захист даних + + + ADP шифрує чутливий вміст даних вашого підрозділу ключами, які належать підрозділу й зберігаються у захищеній службі ключів: + + + Захищено: + + + назви, характер, нотатки та адреси викликів; нотатки й вкладення викликів; контактні дані та нотатки; чутливі дані учасників. + + + Залишається відкритим текстом: + + + системні ідентифікатори, номери викликів, пріоритети, статуси, позначки часу, назви підрозділів та структура — платформі це потрібно для маршрутизації та відображення роботи. + + + Поки діє захист: + + + Пошук, звіти, експорт та сторонні інтеграції не бачать захищеного вмісту. + + + Екрани Big Board показують спрощену оболонку «захищений інцидент» замість деталей виклику. + + + Робочі процеси отримують відредаговані дані (захищені значення замінено на REDACTED). + + + Сповіщення SMS, електронною поштою, push і голосові за замовчуванням надсилають загальний текст («Доступний захищений виклик — увійдіть у Resgrid»). + + + Сам по собі розширений захист даних не є відповідністю HIPAA чи ePCR. Це один із засобів контролю в програмі відповідності, за яку відповідає ваша організація. + + + Крок 2 з 6 — підтвердження + + + Кожен пункт нижче має підтвердити керівний учасник. Ваші підтвердження записуються з позначкою версії. + + + Я розумію, які родини даних захищено (виклики, нотатки/вкладення викликів, контакти, чутливі дані учасників) і що каталог захищених полів версіонується. + + + Я розумію, що системні метадані (номери викликів, пріоритети, статуси, позначки часу, структура підрозділів) залишаються відкритим текстом. + + + Я розумію, що сервери Resgrid обробляють захищені значення в пам’яті, коли авторизований учасник переглядає або редагує їх. + + + Я розумію, що учасникам потрібне свіже підтвердження автентифікатора (MFA) для перегляду захищених значень у вікні, яке налаштовує мій підрозділ (за замовчуванням 15 хвилин; значення понад 60 потребують зафіксованої причини). + + + Я розумію, що екрани Big Board показують спрощені оболонки захищених інцидентів. + + + Я розумію, що робочі процеси отримують відредаговані дані й не можуть діяти на захищений вміст. + + + Я розумію, що сповіщення SMS/email/push/голос за замовчуванням надсилають загальний вміст, а послаблення каналу потребує окремо підтвердженої зміни політики. + + + Я розумію, що пошук, звіти, експорт, інтеграції та офлайн-доступ не бачать захищеного вмісту. + + + Я розумію, що ввімкнення запускає нічну міграцію, а подальше вимкнення розшифровує мої дані в нічних вікнах наприкінці платіжного періоду. + + + Я розумію, що підтримка Resgrid не може читати захищені значення без явного, аудитованого й схваленого підрозділом дозволу, і що втрата ключів відновлюється лише за задокументованою процедурою. + + + Я розумію, що сам по собі розширений захист даних не є відповідністю HIPAA/ePCR. + + + Крок 3 з 6 — попередні перевірки + + + Керівний учасник + + + Платний план + + + Активне доповнення ADP + + + Доступність реєстрації (шлюз платформи) + + + Стан підрозділу дозволяє реєстрацію + + + Служба захисту доступна + + + Керівний учасник має налаштований автентифікатор + + + Попередня перевірка не пройдена. Виправте позначені ✖ пункти та перезавантажте сторінку — реєстрацію не можна поставити в чергу, доки всі перевірки не пройдуть. + + + Крок 4 з 6 — оцінка обсягу міграції + + + Скан лише для читання підраховує рядки вашого підрозділу й оцінює, скільки нічних вікон потрібно міграції. Цей скан нічого не змінює. + + + Запустити скан обсягу + + + Крок 5 з 6 — нічне вікно міграції + + + Міграція виконується лише в цьому локальному вікні підрозділу й призупиняє введення даних. Виберіть найспокійніші години; за замовчуванням {0}–{1}. + + + Початок вікна (місцевий час) + + + Кінець вікна (місцевий час) + + + Часовий пояс + + + Пауза в роботі підрозділу. + + + Поки триває вікно міграції, для вашого підрозділу не обробляються нові виклики, зміни статусів, зміни складу чи заплановані завдання. Перегляд працює. Активне вікно можна перервати будь-коли, і підрозділ одразу повертається до повної роботи. + + + Я розумію й погоджуюся на нічну паузу в роботі під час вікон міграції. + + + Крок 6 з 6 — підтвердити й поставити в чергу + + + Постановка реєстрації в чергу повторно перевіряє кожну вимогу на сервері й додає ваш підрозділ до черги міграції. Підрозділи мігрують по одному; ви отримаєте лист із розкладом. + + + Поставити реєстрацію в чергу + + + Перед постановкою реєстрації в чергу потрібно позначити всі підтвердження. + + + Перед постановкою реєстрації в чергу потрібно погодитися на нічну паузу в роботі. + + + Лише керівний учасник підрозділу може виконати цю команду. + + + Потрібне активне доповнення розширеного захисту даних. + + + Розширений захист даних потребує платного плану. + + + Реєстрація розширеного захисту даних тимчасово недоступна. + + + Стан захисту підрозділу не дозволяє цю команду. Перезавантажте сторінку для поточного статусу. + + + Потрібен дійсний часовий пояс вікна міграції. + + + Команду не вдалося виконати; можна спробувати ще раз. + + + Скасувати реєстрацію в черзі? Нічого ще не мігровано; ви зможете зареєструватися пізніше, поки доповнення активне. + + + Залишити розширений захист даних активним? Заплановане відключення буде скасовано. + + + Екстрені контакти + + + Екстрені контакти є специфічними для цього підрозділу й зберігаються згідно з його налаштуваннями захисту даних. Можна додати кілька. + + + Додати екстрений контакт + + + Редагувати екстрений контакт + + + Екстрений контакт + + + Екстрених контактів не додано. + + + Ім'я + + + Стосунок + + + Номер телефону + + + Додатковий номер телефону + + + Електронна пошта + + + Нотатки + + + Основний контакт + + + Основний + + + Додатковий + + + Так + + + Редагувати + + + Видалити + + + Зберегти + + + Скасувати + + + Потрібно вказати ім'я. + + + Не вдалося зберегти контакт. + + + Видалити цей екстрений контакт? + + diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx index 6cf84ce33..b2892dc39 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx @@ -1,98 +1,120 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - التقويم - - - مرحبًا {0}، - -تصدير بياناتك الشخصية جاهز للتنزيل. - -رابط التنزيل: {1} - -تنتهي صلاحية هذا الرابط في {2:f} بتوقيت UTC. بعد هذا التاريخ لن تكون البيانات متاحة. - -إذا لم تطلب ذلك، يرجى التواصل مع الدعم. - -فريق Resgrid - - - تصدير بياناتك من Resgrid جاهز - - - إشعار - - - مرحبًا {0}، - -رمز التحقق الخاص بك في Resgrid هو: {1} - -تنتهي صلاحية هذا الرمز خلال {2} دقيقة. إذا لم تطلب ذلك، فتجاهل هذا البريد. - -فريق Resgrid - - - رمز التحقق من Resgrid - - - رمز التحقق الخاص بك في Resgrid هو: {0}. تنتهي صلاحيته خلال {1} دقيقة. - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + التقويم + + + مرحبًا {0}، + + + +تصدير بياناتك الشخصية جاهز للتنزيل. + + + +رابط التنزيل: {1} + + + +تنتهي صلاحية هذا الرابط في {2:f} بتوقيت UTC. بعد هذا التاريخ لن تكون البيانات متاحة. + + + +إذا لم تطلب ذلك، يرجى التواصل مع الدعم. + + + +فريق Resgrid + + + تستخدم إدارتك الحماية المتقدمة للبيانات، لذلك فإن بعض القيم في هذا التصدير مشفّرة وتظهر بالعلامة REDACTED. وهي لا تزال جزءًا من سجلك ولم يتم حذفها. + + + لعرض هذه القيم، سجّل الدخول إلى Resgrid وافتح الصفحة التي تنتمي إليها، حيث يمكنك إظهارها بعد تأكيد هويتك. كما يمكن لمسؤول إدارتك تزويدك بها. + + + تصدير بياناتك من Resgrid جاهز + + + إشعار + + + مرحبًا {0}، + + + +رمز التحقق الخاص بك في Resgrid هو: {1} + + + +تنتهي صلاحية هذا الرمز خلال {2} دقيقة. إذا لم تطلب ذلك، فتجاهل هذا البريد. + + + +فريق Resgrid + + + رمز التحقق من Resgrid + + + رمز التحقق الخاص بك في Resgrid هو: {0}. تنتهي صلاحيته خلال {1} دقيقة. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx index 4ec77a552..2221acca9 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx @@ -1,98 +1,120 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Kalender - - - Hallo {0}, - -Ihr Export personenbezogener Daten steht zum Download bereit. - -Download-Link: {1} - -Dieser Link läuft am {2:f} UTC ab. Danach sind die Daten nicht mehr verfügbar. - -Falls Sie dies nicht angefordert haben, wenden Sie sich bitte an den Support. - -Ihr Resgrid-Team - - - Ihr Resgrid-Datenexport ist bereit - - - Benachrichtigung - - - Hallo {0}, - -Ihr Resgrid-Bestätigungscode lautet: {1} - -Dieser Code läuft in {2} Minuten ab. Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail bitte. - -Ihr Resgrid-Team - - - Resgrid-Bestätigungscode - - - Ihr Resgrid-Bestätigungscode lautet: {0}. Er läuft in {1} Minuten ab. - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Kalender + + + Hallo {0}, + + + +Ihr Export personenbezogener Daten steht zum Download bereit. + + + +Download-Link: {1} + + + +Dieser Link läuft am {2:f} UTC ab. Danach sind die Daten nicht mehr verfügbar. + + + +Falls Sie dies nicht angefordert haben, wenden Sie sich bitte an den Support. + + + +Ihr Resgrid-Team + + + Ihre Abteilung verwendet den erweiterten Datenschutz. Daher sind einige Werte in diesem Export verschlüsselt und werden als REDACTED angezeigt. Sie gehören weiterhin zu Ihren Daten und wurden nicht gelöscht. + + + Um diese Werte zu sehen, melden Sie sich bei Resgrid an und öffnen Sie die zugehörige Seite. Dort können Sie sie nach einer Identitätsbestätigung anzeigen. Ihre Abteilungsadministration kann sie Ihnen ebenfalls zur Verfügung stellen. + + + Ihr Resgrid-Datenexport ist bereit + + + Benachrichtigung + + + Hallo {0}, + + + +Ihr Resgrid-Bestätigungscode lautet: {1} + + + +Dieser Code läuft in {2} Minuten ab. Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail bitte. + + + +Ihr Resgrid-Team + + + Resgrid-Bestätigungscode + + + Ihr Resgrid-Bestätigungscode lautet: {0}. Er läuft in {1} Minuten ab. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx index ab749728b..e6bcf615e 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx @@ -1,98 +1,120 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Ημερολόγιο - - - Γεια σας {0}, - -Η εξαγωγή των προσωπικών σας δεδομένων είναι έτοιμη για λήψη. - -Σύνδεσμος λήψης: {1} - -Ο σύνδεσμος λήγει στις {2:f} UTC. Μετά την ημερομηνία αυτή τα δεδομένα δεν θα είναι πλέον διαθέσιμα. - -Αν δεν κάνατε εσείς αυτό το αίτημα, επικοινωνήστε με την υποστήριξη. - -Η ομάδα του Resgrid - - - Η εξαγωγή δεδομένων σας από το Resgrid είναι έτοιμη - - - Ειδοποίηση - - - Γεια σας {0}, - -Ο κωδικός επαλήθευσης Resgrid είναι: {1} - -Ο κωδικός λήγει σε {2} λεπτά. Αν δεν κάνατε εσείς αυτό το αίτημα, αγνοήστε αυτό το email. - -Η ομάδα του Resgrid - - - Κωδικός επαλήθευσης Resgrid - - - Ο κωδικός επαλήθευσης Resgrid είναι: {0}. Λήγει σε {1} λεπτά. - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Ημερολόγιο + + + Γεια σας {0}, + + + +Η εξαγωγή των προσωπικών σας δεδομένων είναι έτοιμη για λήψη. + + + +Σύνδεσμος λήψης: {1} + + + +Ο σύνδεσμος λήγει στις {2:f} UTC. Μετά την ημερομηνία αυτή τα δεδομένα δεν θα είναι πλέον διαθέσιμα. + + + +Αν δεν κάνατε εσείς αυτό το αίτημα, επικοινωνήστε με την υποστήριξη. + + + +Η ομάδα του Resgrid + + + Η υπηρεσία σας χρησιμοποιεί την Προηγμένη Προστασία Δεδομένων, επομένως ορισμένες τιμές σε αυτήν την εξαγωγή είναι κρυπτογραφημένες και εμφανίζονται ως REDACTED. Εξακολουθούν να αποτελούν μέρος των δεδομένων σας και δεν έχουν διαγραφεί. + + + Για να δείτε αυτές τις τιμές, συνδεθείτε στο Resgrid και ανοίξτε τη σελίδα στην οποία ανήκουν, όπου μπορείτε να τις εμφανίσετε αφού επιβεβαιώσετε την ταυτότητά σας. Μπορεί επίσης να σας τις δώσει ο διαχειριστής της υπηρεσίας σας. + + + Η εξαγωγή δεδομένων σας από το Resgrid είναι έτοιμη + + + Ειδοποίηση + + + Γεια σας {0}, + + + +Ο κωδικός επαλήθευσης Resgrid είναι: {1} + + + +Ο κωδικός λήγει σε {2} λεπτά. Αν δεν κάνατε εσείς αυτό το αίτημα, αγνοήστε αυτό το email. + + + +Η ομάδα του Resgrid + + + Κωδικός επαλήθευσης Resgrid + + + Ο κωδικός επαλήθευσης Resgrid είναι: {0}. Λήγει σε {1} λεπτά. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx index 0ad526318..f554ee51f 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx @@ -1,98 +1,120 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Calendar - - - Hi {0}, - -Your personal data export is ready for download. - -Download link: {1} - -This link expires on {2:f} UTC. After this date the data will no longer be available. - -If you did not request this, please contact support. - -The Resgrid Team - - - Your Resgrid Data Export is Ready - - - Notification - - - Hi {0}, - -Your Resgrid verification code is: {1} - -This code expires in {2} minutes. If you did not request this, please ignore this email. - -The Resgrid Team - - - Resgrid Verification Code - - - Your Resgrid verification code is: {0}. It expires in {1} minutes. - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Calendar + + + Hi {0}, + + + +Your personal data export is ready for download. + + + +Download link: {1} + + + +This link expires on {2:f} UTC. After this date the data will no longer be available. + + + +If you did not request this, please contact support. + + + +The Resgrid Team + + + Your department uses Advanced Data Protection, so some of the values in this export are encrypted and appear as REDACTED. They are still part of your record and have not been deleted. + + + To see these values, sign in to Resgrid and open the page they belong to, where you can reveal them after confirming your identity. Your department administrator can also provide them. + + + Your Resgrid Data Export is Ready + + + Notification + + + Hi {0}, + + + +Your Resgrid verification code is: {1} + + + +This code expires in {2} minutes. If you did not request this, please ignore this email. + + + +The Resgrid Team + + + Resgrid Verification Code + + + Your Resgrid verification code is: {0}. It expires in {1} minutes. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx index 02d7e9fd2..5388e2071 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx @@ -1,98 +1,120 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Calendario - - - Hola {0}: - -Su exportación de datos personales está lista para descargar. - -Enlace de descarga: {1} - -Este enlace caduca el {2:f} UTC. Después de esa fecha los datos dejarán de estar disponibles. - -Si no solicitó esto, contacte con el soporte. - -El equipo de Resgrid - - - Su exportación de datos de Resgrid está lista - - - Notificación - - - Hola {0}: - -Su código de verificación de Resgrid es: {1} - -Este código caduca en {2} minutos. Si no lo solicitó, ignore este correo. - -El equipo de Resgrid - - - Código de verificación de Resgrid - - - Su código de verificación de Resgrid es: {0}. Caduca en {1} minutos. - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Calendario + + + Hola {0}: + + + +Su exportación de datos personales está lista para descargar. + + + +Enlace de descarga: {1} + + + +Este enlace caduca el {2:f} UTC. Después de esa fecha los datos dejarán de estar disponibles. + + + +Si no solicitó esto, contacte con el soporte. + + + +El equipo de Resgrid + + + Su departamento utiliza la Protección de Datos Avanzada, por lo que algunos valores de esta exportación están cifrados y aparecen como REDACTED. Siguen formando parte de su expediente y no se han eliminado. + + + Para ver estos valores, inicie sesión en Resgrid y abra la página a la que pertenecen, donde podrá mostrarlos tras confirmar su identidad. La administración de su departamento también puede facilitárselos. + + + Su exportación de datos de Resgrid está lista + + + Notificación + + + Hola {0}: + + + +Su código de verificación de Resgrid es: {1} + + + +Este código caduca en {2} minutos. Si no lo solicitó, ignore este correo. + + + +El equipo de Resgrid + + + Código de verificación de Resgrid + + + Su código de verificación de Resgrid es: {0}. Caduca en {1} minutos. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx index 8052b0bd7..61d4f27db 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx @@ -1,98 +1,120 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Calendrier - - - Bonjour {0}, - -Votre export de données personnelles est prêt à être téléchargé. - -Lien de téléchargement : {1} - -Ce lien expire le {2:f} UTC. Passé cette date, les données ne seront plus disponibles. - -Si vous n'êtes pas à l'origine de cette demande, contactez le support. - -L'équipe Resgrid - - - Votre export de données Resgrid est prêt - - - Notification - - - Bonjour {0}, - -Votre code de vérification Resgrid est : {1} - -Ce code expire dans {2} minutes. Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. - -L'équipe Resgrid - - - Code de vérification Resgrid - - - Votre code de vérification Resgrid est : {0}. Il expire dans {1} minutes. - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Calendrier + + + Bonjour {0}, + + + +Votre export de données personnelles est prêt à être téléchargé. + + + +Lien de téléchargement : {1} + + + +Ce lien expire le {2:f} UTC. Passé cette date, les données ne seront plus disponibles. + + + +Si vous n'êtes pas à l'origine de cette demande, contactez le support. + + + +L'équipe Resgrid + + + Votre service utilise la Protection avancée des données : certaines valeurs de cet export sont chiffrées et apparaissent sous la mention REDACTED. Elles font toujours partie de votre dossier et n'ont pas été supprimées. + + + Pour consulter ces valeurs, connectez-vous à Resgrid et ouvrez la page correspondante, où vous pourrez les afficher après confirmation de votre identité. L'administrateur de votre service peut également vous les communiquer. + + + Votre export de données Resgrid est prêt + + + Notification + + + Bonjour {0}, + + + +Votre code de vérification Resgrid est : {1} + + + +Ce code expire dans {2} minutes. Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail. + + + +L'équipe Resgrid + + + Code de vérification Resgrid + + + Votre code de vérification Resgrid est : {0}. Il expire dans {1} minutes. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx index 5d5d30ebf..15852db9b 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx @@ -1,98 +1,120 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Calendario - - - Ciao {0}, - -L'esportazione dei tuoi dati personali è pronta per il download. - -Link per il download: {1} - -Questo link scade il {2:f} UTC. Dopo tale data i dati non saranno più disponibili. - -Se non hai richiesto tu questa operazione, contatta l'assistenza. - -Il team Resgrid - - - La tua esportazione dati Resgrid è pronta - - - Notifica - - - Ciao {0}, - -Il tuo codice di verifica Resgrid è: {1} - -Questo codice scade tra {2} minuti. Se non hai richiesto tu questa operazione, ignora questa email. - -Il team Resgrid - - - Codice di verifica Resgrid - - - Il tuo codice di verifica Resgrid è: {0}. Scade tra {1} minuti. - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Calendario + + + Ciao {0}, + + + +L'esportazione dei tuoi dati personali è pronta per il download. + + + +Link per il download: {1} + + + +Questo link scade il {2:f} UTC. Dopo tale data i dati non saranno più disponibili. + + + +Se non hai richiesto tu questa operazione, contatta l'assistenza. + + + +Il team Resgrid + + + Il tuo dipartimento utilizza la Protezione avanzata dei dati, quindi alcuni valori di questa esportazione sono cifrati e compaiono come REDACTED. Fanno comunque parte dei tuoi dati e non sono stati eliminati. + + + Per vedere questi valori, accedi a Resgrid e apri la pagina a cui appartengono, dove puoi mostrarli dopo aver confermato la tua identità. Anche l'amministratore del dipartimento può fornirteli. + + + La tua esportazione dati Resgrid è pronta + + + Notifica + + + Ciao {0}, + + + +Il tuo codice di verifica Resgrid è: {1} + + + +Questo codice scade tra {2} minuti. Se non hai richiesto tu questa operazione, ignora questa email. + + + +Il team Resgrid + + + Codice di verifica Resgrid + + + Il tuo codice di verifica Resgrid è: {0}. Scade tra {1} minuti. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx index 586d1b59a..b5a9e1dad 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx @@ -1,98 +1,120 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Kalendarz - - - Cześć {0}, - -Eksport Twoich danych osobowych jest gotowy do pobrania. - -Link do pobrania: {1} - -Link wygasa {2:f} UTC. Po tej dacie dane nie będą już dostępne. - -Jeśli to nie Ty prosiłeś o eksport, skontaktuj się z pomocą techniczną. - -Zespół Resgrid - - - Twój eksport danych Resgrid jest gotowy - - - Powiadomienie - - - Cześć {0}, - -Twój kod weryfikacyjny Resgrid to: {1} - -Kod wygaśnie za {2} minut. Jeśli to nie Ty prosiłeś o kod, zignoruj tę wiadomość. - -Zespół Resgrid - - - Kod weryfikacyjny Resgrid - - - Twój kod weryfikacyjny Resgrid to: {0}. Wygaśnie za {1} minut. - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Kalendarz + + + Cześć {0}, + + + +Eksport Twoich danych osobowych jest gotowy do pobrania. + + + +Link do pobrania: {1} + + + +Link wygasa {2:f} UTC. Po tej dacie dane nie będą już dostępne. + + + +Jeśli to nie Ty prosiłeś o eksport, skontaktuj się z pomocą techniczną. + + + +Zespół Resgrid + + + Twoja jednostka korzysta z zaawansowanej ochrony danych, dlatego część wartości w tym eksporcie jest zaszyfrowana i wyświetla się jako REDACTED. Nadal stanowią one część Twoich danych i nie zostały usunięte. + + + Aby zobaczyć te wartości, zaloguj się do Resgrid i otwórz stronę, do której należą — możesz je tam wyświetlić po potwierdzeniu tożsamości. Może je również udostępnić administrator Twojej jednostki. + + + Twój eksport danych Resgrid jest gotowy + + + Powiadomienie + + + Cześć {0}, + + + +Twój kod weryfikacyjny Resgrid to: {1} + + + +Kod wygaśnie za {2} minut. Jeśli to nie Ty prosiłeś o kod, zignoruj tę wiadomość. + + + +Zespół Resgrid + + + Kod weryfikacyjny Resgrid + + + Twój kod weryfikacyjny Resgrid to: {0}. Wygaśnie za {1} minut. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx index 749912629..ffc73ed3f 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx @@ -1,98 +1,120 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Kalender - - - Hej {0}, - -Din export av personuppgifter är klar för nedladdning. - -Nedladdningslänk: {1} - -Länken upphör att gälla {2:f} UTC. Efter detta datum är uppgifterna inte längre tillgängliga. - -Om du inte har begärt detta, kontakta supporten. - -Resgrid-teamet - - - Din Resgrid-dataexport är klar - - - Avisering - - - Hej {0}, - -Din Resgrid-verifieringskod är: {1} - -Koden upphör att gälla om {2} minuter. Om du inte har begärt detta kan du ignorera detta e-postmeddelande. - -Resgrid-teamet - - - Resgrid verifieringskod - - - Din Resgrid-verifieringskod är: {0}. Den upphör att gälla om {1} minuter. - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Kalender + + + Hej {0}, + + + +Din export av personuppgifter är klar för nedladdning. + + + +Nedladdningslänk: {1} + + + +Länken upphör att gälla {2:f} UTC. Efter detta datum är uppgifterna inte längre tillgängliga. + + + +Om du inte har begärt detta, kontakta supporten. + + + +Resgrid-teamet + + + Din organisation använder avancerat dataskydd, så vissa värden i den här exporten är krypterade och visas som REDACTED. De ingår fortfarande i dina uppgifter och har inte raderats. + + + Logga in i Resgrid och öppna sidan där värdena hör hemma för att visa dem efter att du bekräftat din identitet. Din organisationsadministratör kan också lämna ut dem. + + + Din Resgrid-dataexport är klar + + + Avisering + + + Hej {0}, + + + +Din Resgrid-verifieringskod är: {1} + + + +Koden upphör att gälla om {2} minuter. Om du inte har begärt detta kan du ignorera detta e-postmeddelande. + + + +Resgrid-teamet + + + Resgrid verifieringskod + + + Din Resgrid-verifieringskod är: {0}. Den upphör att gälla om {1} minuter. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx index 77ed080eb..77dc384db 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx @@ -1,98 +1,120 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - text/microsoft-resx - - - 2.0 - - - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - - Календар - - - Вітаємо, {0}! - -Експорт ваших персональних даних готовий до завантаження. - -Посилання для завантаження: {1} - -Посилання діє до {2:f} UTC. Після цієї дати дані будуть недоступні. - -Якщо ви не надсилали запит, зверніться до служби підтримки. - -Команда Resgrid - - - Ваш експорт даних Resgrid готовий - - - Сповіщення - - - Вітаємо, {0}! - -Ваш код підтвердження Resgrid: {1} - -Код діє {2} хв. Якщо ви не надсилали запит, просто проігноруйте цей лист. - -Команда Resgrid - - - Код підтвердження Resgrid - - - Ваш код підтвердження Resgrid: {0}. Діє {1} хв. - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Календар + + + Вітаємо, {0}! + + + +Експорт ваших персональних даних готовий до завантаження. + + + +Посилання для завантаження: {1} + + + +Посилання діє до {2:f} UTC. Після цієї дати дані будуть недоступні. + + + +Якщо ви не надсилали запит, зверніться до служби підтримки. + + + +Команда Resgrid + + + Ваш підрозділ використовує розширений захист даних, тому деякі значення в цьому експорті зашифровані й відображаються як REDACTED. Вони й далі є частиною ваших даних і не були видалені. + + + Щоб побачити ці значення, увійдіть у Resgrid і відкрийте сторінку, до якої вони належать, — там ви зможете показати їх після підтвердження особи. Їх також може надати адміністратор вашого підрозділу. + + + Ваш експорт даних Resgrid готовий + + + Сповіщення + + + Вітаємо, {0}! + + + +Ваш код підтвердження Resgrid: {1} + + + +Код діє {2} хв. Якщо ви не надсилали запит, просто проігноруйте цей лист. + + + +Команда Resgrid + + + Код підтвердження Resgrid + + + Ваш код підтвердження Resgrid: {0}. Діє {1} хв. + \ No newline at end of file diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx index f9fe0a171..8e2a172ab 100644 --- a/Core/Resgrid.Localization/Common.ar.resx +++ b/Core/Resgrid.Localization/Common.ar.resx @@ -199,4 +199,64 @@ البلاغات المحطات نقاط الاهتمام + + بلاغ محمي + + + الشهادات المحمية + + + جهة اتصال محمية + + + مشفّر أثناء التخزين لهذه الإدارة. لا يزال بإمكان المستخدمين المصرح لهم والقنوات المعتمدة الكشف عنه. + + + التحقق والكشف + + + إخفاء + + + تحقق لعرض البيانات المحمية + + + أدخل الرمز المكوّن من 6 أرقام من تطبيق المصادقة. تبقى القيم المعروضة ظاهرة فقط خلال نافذة التحقق الخاصة بهذه الإدارة ولا يتم تخزينها أبدًا في متصفحك. + + + رمز التحقق + + + تحقق + + + رمز التحقق غير صالح أو انتهت صلاحيته. + + + عدد كبير جدًا من محاولات التحقق. انتظر بضع دقائق ثم حاول مرة أخرى. + + + لم يتم تفعيل المصادقة الثنائية لهذا الحساب. فعّل تطبيق مصادقة من إعدادات أمان الحساب أولاً. + + + لم يتم تكوين الوصول إلى البيانات المحمية على هذا الخادم. + + + مطلوب التحقق مرة أخرى. + + + انتهت نافذة التحقق. تحقق مرة أخرى. + + + تم إلغاء الوصول بسبب تغيير في السياسة. تحقق مرة أخرى. + + + ليست لديك صلاحية عرض هذه البيانات المحمية. + + + خدمة البيانات المحمية غير متاحة. حاول مرة أخرى بعد قليل. + + + فشل الطلب. حاول مرة أخرى. + diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx index f1256ad63..b8217b7ac 100644 --- a/Core/Resgrid.Localization/Common.de.resx +++ b/Core/Resgrid.Localization/Common.de.resx @@ -632,4 +632,64 @@ POIs + + Geschützter Einsatz + + + Geschützte Zertifikate + + + Geschützter Kontakt + + + für diese Abteilung im Ruhezustand verschlüsselt. Berechtigte Benutzer und freigegebene Kanäle können sie dennoch offenlegen. + + + Bestätigen & anzeigen + + + Verbergen + + + Bestätigen, um geschützte Daten anzuzeigen + + + Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein. Angezeigte Werte bleiben nur während des Bestätigungszeitfensters dieser Abteilung sichtbar und werden niemals in Ihrem Browser gespeichert. + + + Bestätigungscode + + + Bestätigen + + + Der Bestätigungscode ist ungültig oder abgelaufen. + + + Zu viele Bestätigungsversuche. Warten Sie einige Minuten und versuchen Sie es erneut. + + + Für dieses Konto ist keine Zwei-Faktor-Authentifizierung eingerichtet. Richten Sie zuerst eine Authenticator-App in den Sicherheitseinstellungen des Kontos ein. + + + Der Zugriff auf geschützte Daten ist auf diesem Server nicht konfiguriert. + + + Eine erneute Bestätigung ist erforderlich. + + + Das Bestätigungszeitfenster ist abgelaufen. Bitte erneut bestätigen. + + + Der Zugriff wurde durch eine Richtlinienänderung widerrufen. Bitte erneut bestätigen. + + + Sie sind nicht berechtigt, diese geschützten Daten anzuzeigen. + + + Der Dienst für geschützte Daten ist nicht verfügbar. Versuchen Sie es in Kürze erneut. + + + Die Anfrage ist fehlgeschlagen. Bitte erneut versuchen. + diff --git a/Core/Resgrid.Localization/Common.el.resx b/Core/Resgrid.Localization/Common.el.resx index b22bf65e7..d7c79ed61 100644 --- a/Core/Resgrid.Localization/Common.el.resx +++ b/Core/Resgrid.Localization/Common.el.resx @@ -684,4 +684,64 @@ Τα Τμήματά Σας + + Προστατευμένη κλήση + + + Προστατευμένες πιστοποιήσεις + + + Προστατευμένη επαφή + + + κρυπτογραφημένο σε ηρεμία για αυτό το τμήμα. Οι εξουσιοδοτημένοι χρήστες και τα εγκεκριμένα κανάλια μπορούν παρ’ όλα αυτά να το αποκαλύψουν. + + + Επαλήθευση & εμφάνιση + + + Απόκρυψη + + + Επαληθεύστε για να δείτε τα προστατευμένα δεδομένα + + + Εισαγάγετε τον 6ψήφιο κωδικό από την εφαρμογή ελέγχου ταυτότητας. Οι τιμές που εμφανίζονται παραμένουν ορατές μόνο για το παράθυρο επαλήθευσης αυτού του τμήματος και δεν αποθηκεύονται ποτέ στο πρόγραμμα περιήγησής σας. + + + Κωδικός επαλήθευσης + + + Επαλήθευση + + + Ο κωδικός επαλήθευσης δεν είναι έγκυρος ή έχει λήξει. + + + Πάρα πολλές προσπάθειες επαλήθευσης. Περιμένετε λίγα λεπτά και δοκιμάστε ξανά. + + + Ο έλεγχος ταυτότητας δύο παραγόντων δεν έχει ρυθμιστεί για αυτόν τον λογαριασμό. Ρυθμίστε πρώτα μια εφαρμογή ελέγχου ταυτότητας στις ρυθμίσεις ασφαλείας του λογαριασμού. + + + Η πρόσβαση σε προστατευμένα δεδομένα δεν έχει ρυθμιστεί σε αυτόν τον διακομιστή. + + + Απαιτείται εκ νέου επαλήθευση. + + + Το παράθυρο επαλήθευσης έληξε. Επαληθεύστε ξανά. + + + Η πρόσβαση ανακλήθηκε λόγω αλλαγής πολιτικής. Επαληθεύστε ξανά. + + + Δεν έχετε εξουσιοδότηση να δείτε αυτά τα προστατευμένα δεδομένα. + + + Η υπηρεσία προστατευμένων δεδομένων δεν είναι διαθέσιμη. Δοκιμάστε ξανά σύντομα. + + + Το αίτημα απέτυχε. Δοκιμάστε ξανά. + diff --git a/Core/Resgrid.Localization/Common.en.resx b/Core/Resgrid.Localization/Common.en.resx index 2fd7d7e62..036d249e0 100644 --- a/Core/Resgrid.Localization/Common.en.resx +++ b/Core/Resgrid.Localization/Common.en.resx @@ -684,4 +684,64 @@ Your Departments + + Protected call + + + Protected certifications + + + Protected contact + + + encrypted at rest for this department. Authorized users and approved channels may still disclose it. + + + Verify & Reveal + + + Conceal + + + Verify to view protected data + + + Enter the 6-digit code from your authenticator app. Revealed values stay visible only for this department's step-up window and are never stored in your browser. + + + Verification code + + + Verify + + + The verification code is invalid or has expired. + + + Too many verification attempts. Wait a few minutes and try again. + + + Two-factor authentication is not enrolled for this account. Enroll an authenticator app in account security settings first. + + + Protected data access is not configured on this server. + + + Verification is required again. + + + The verification window expired. Verify again. + + + Access was revoked by a policy change. Verify again. + + + You are not authorized to view this protected data. + + + The protected data service is unavailable. Try again shortly. + + + The request failed. Try again. + diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx index fb6971c67..477b04e98 100644 --- a/Core/Resgrid.Localization/Common.es.resx +++ b/Core/Resgrid.Localization/Common.es.resx @@ -672,4 +672,64 @@ PDI + + Llamada protegida + + + Certificaciones protegidas + + + Contacto protegido + + + cifrado en reposo para este departamento. Los usuarios autorizados y los canales aprobados aún pueden divulgarlo. + + + Verificar y mostrar + + + Ocultar + + + Verifique para ver los datos protegidos + + + Introduzca el código de 6 dígitos de su aplicación de autenticación. Los valores mostrados permanecen visibles solo durante la ventana de verificación de este departamento y nunca se almacenan en su navegador. + + + Código de verificación + + + Verificar + + + El código de verificación no es válido o ha caducado. + + + Demasiados intentos de verificación. Espere unos minutos e inténtelo de nuevo. + + + Esta cuenta no tiene configurada la autenticación de dos factores. Configure primero una aplicación de autenticación en los ajustes de seguridad de la cuenta. + + + El acceso a datos protegidos no está configurado en este servidor. + + + Se requiere verificar de nuevo. + + + La ventana de verificación ha caducado. Verifique de nuevo. + + + El acceso fue revocado por un cambio de política. Verifique de nuevo. + + + No está autorizado para ver estos datos protegidos. + + + El servicio de datos protegidos no está disponible. Inténtelo de nuevo en breve. + + + La solicitud falló. Inténtelo de nuevo. + diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx index 33142dd61..6db576e73 100644 --- a/Core/Resgrid.Localization/Common.fr.resx +++ b/Core/Resgrid.Localization/Common.fr.resx @@ -632,4 +632,64 @@ POI + + Appel protégé + + + Certifications protégées + + + Contact protégé + + + chiffré au repos pour ce département. Les utilisateurs autorisés et les canaux approuvés peuvent tout de même le divulguer. + + + Vérifier et afficher + + + Masquer + + + Vérifiez pour afficher les données protégées + + + Saisissez le code à 6 chiffres de votre application d'authentification. Les valeurs affichées ne restent visibles que pendant la fenêtre de vérification de ce département et ne sont jamais stockées dans votre navigateur. + + + Code de vérification + + + Vérifier + + + Le code de vérification est invalide ou a expiré. + + + Trop de tentatives de vérification. Attendez quelques minutes et réessayez. + + + L'authentification à deux facteurs n'est pas configurée pour ce compte. Configurez d'abord une application d'authentification dans les paramètres de sécurité du compte. + + + L'accès aux données protégées n'est pas configuré sur ce serveur. + + + Une nouvelle vérification est requise. + + + La fenêtre de vérification a expiré. Vérifiez à nouveau. + + + L'accès a été révoqué par un changement de politique. Vérifiez à nouveau. + + + Vous n'êtes pas autorisé à consulter ces données protégées. + + + Le service de données protégées est indisponible. Réessayez dans quelques instants. + + + La requête a échoué. Réessayez. + diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx index 80ee8a8ac..ccdad14e6 100644 --- a/Core/Resgrid.Localization/Common.it.resx +++ b/Core/Resgrid.Localization/Common.it.resx @@ -632,4 +632,64 @@ POI + + Chiamata protetta + + + Certificazioni protette + + + Contatto protetto + + + crittografato a riposo per questo dipartimento. Gli utenti autorizzati e i canali approvati possono comunque divulgarlo. + + + Verifica e mostra + + + Nascondi + + + Verifica per visualizzare i dati protetti + + + Inserisci il codice a 6 cifre della tua app di autenticazione. I valori mostrati restano visibili solo per la finestra di verifica di questo dipartimento e non vengono mai memorizzati nel browser. + + + Codice di verifica + + + Verifica + + + Il codice di verifica non è valido o è scaduto. + + + Troppi tentativi di verifica. Attendi qualche minuto e riprova. + + + L'autenticazione a due fattori non è configurata per questo account. Configura prima un'app di autenticazione nelle impostazioni di sicurezza dell'account. + + + L'accesso ai dati protetti non è configurato su questo server. + + + È necessaria una nuova verifica. + + + La finestra di verifica è scaduta. Verifica di nuovo. + + + L'accesso è stato revocato da una modifica dei criteri. Verifica di nuovo. + + + Non sei autorizzato a visualizzare questi dati protetti. + + + Il servizio dati protetti non è disponibile. Riprova a breve. + + + La richiesta non è riuscita. Riprova. + diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx index d8c95b591..4e80ff153 100644 --- a/Core/Resgrid.Localization/Common.pl.resx +++ b/Core/Resgrid.Localization/Common.pl.resx @@ -632,4 +632,64 @@ POI + + Zgłoszenie chronione + + + Chronione certyfikaty + + + Kontakt chroniony + + + zaszyfrowane w spoczynku dla tego departamentu. Uprawnieni użytkownicy i zatwierdzone kanały nadal mogą je ujawnić. + + + Zweryfikuj i pokaż + + + Ukryj + + + Zweryfikuj, aby zobaczyć chronione dane + + + Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej. Pokazane wartości pozostają widoczne tylko przez okno weryfikacji tego departamentu i nigdy nie są przechowywane w przeglądarce. + + + Kod weryfikacyjny + + + Zweryfikuj + + + Kod weryfikacyjny jest nieprawidłowy lub wygasł. + + + Zbyt wiele prób weryfikacji. Odczekaj kilka minut i spróbuj ponownie. + + + Uwierzytelnianie dwuskładnikowe nie jest skonfigurowane dla tego konta. Najpierw skonfiguruj aplikację uwierzytelniającą w ustawieniach zabezpieczeń konta. + + + Dostęp do chronionych danych nie jest skonfigurowany na tym serwerze. + + + Wymagana jest ponowna weryfikacja. + + + Okno weryfikacji wygasło. Zweryfikuj ponownie. + + + Dostęp został cofnięty przez zmianę zasad. Zweryfikuj ponownie. + + + Nie masz uprawnień do wyświetlenia tych chronionych danych. + + + Usługa chronionych danych jest niedostępna. Spróbuj ponownie za chwilę. + + + Żądanie nie powiodło się. Spróbuj ponownie. + diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx index c9761d735..3f3509f30 100644 --- a/Core/Resgrid.Localization/Common.sv.resx +++ b/Core/Resgrid.Localization/Common.sv.resx @@ -632,4 +632,64 @@ POI + + Skyddat larm + + + Skyddade certifikat + + + Skyddad kontakt + + + krypterat i vila för denna organisation. Behöriga användare och godkända kanaler kan ändå röja det. + + + Verifiera och visa + + + Dölj + + + Verifiera för att visa skyddade uppgifter + + + Ange den 6-siffriga koden från din autentiseringsapp. Visade värden är synliga endast under denna organisations verifieringsfönster och lagras aldrig i din webbläsare. + + + Verifieringskod + + + Verifiera + + + Verifieringskoden är ogiltig eller har upphört att gälla. + + + För många verifieringsförsök. Vänta några minuter och försök igen. + + + Tvåfaktorsautentisering är inte aktiverad för detta konto. Aktivera först en autentiseringsapp i kontots säkerhetsinställningar. + + + Åtkomst till skyddade uppgifter är inte konfigurerad på denna server. + + + Verifiering krävs igen. + + + Verifieringsfönstret har gått ut. Verifiera igen. + + + Åtkomsten återkallades av en policyändring. Verifiera igen. + + + Du har inte behörighet att visa dessa skyddade uppgifter. + + + Tjänsten för skyddade uppgifter är otillgänglig. Försök igen om en stund. + + + Begäran misslyckades. Försök igen. + diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx index 1436f122f..1dbef717f 100644 --- a/Core/Resgrid.Localization/Common.uk.resx +++ b/Core/Resgrid.Localization/Common.uk.resx @@ -632,4 +632,64 @@ POI + + Захищений виклик + + + Захищені сертифікати + + + Захищений контакт + + + зашифровано в стані спокою для цього підрозділу. Авторизовані користувачі та схвалені канали все одно можуть його розкрити. + + + Підтвердити й показати + + + Приховати + + + Підтвердьте, щоб переглянути захищені дані + + + Введіть 6-значний код із застосунку автентифікації. Показані значення залишаються видимими лише протягом вікна підтвердження цього підрозділу й ніколи не зберігаються у вашому браузері. + + + Код підтвердження + + + Підтвердити + + + Код підтвердження недійсний або протермінований. + + + Забагато спроб підтвердження. Зачекайте кілька хвилин і спробуйте ще раз. + + + Для цього облікового запису не налаштовано двофакторну автентифікацію. Спершу налаштуйте застосунок автентифікації в параметрах безпеки облікового запису. + + + Доступ до захищених даних не налаштовано на цьому сервері. + + + Потрібне повторне підтвердження. + + + Вікно підтвердження завершилося. Підтвердьте ще раз. + + + Доступ відкликано через зміну політики. Підтвердьте ще раз. + + + Ви не маєте дозволу переглядати ці захищені дані. + + + Служба захищених даних недоступна. Спробуйте ще раз незабаром. + + + Запит не вдався. Спробуйте ще раз. + diff --git a/Core/Resgrid.Model/AdpMigrationNightContext.cs b/Core/Resgrid.Model/AdpMigrationNightContext.cs index 835e03c0a..109db4ad6 100644 --- a/Core/Resgrid.Model/AdpMigrationNightContext.cs +++ b/Core/Resgrid.Model/AdpMigrationNightContext.cs @@ -19,6 +19,13 @@ public sealed class AdpMigrationNightContext /// Catalog version this run migrates to. public int CatalogVersion { get; set; } + /// + /// Catalog version the department is migrating FROM. Zero for enrollment and offboarding + /// (the whole catalog is in scope); for a CatalogUpgrade it is the department's pinned + /// version, and only fields added after it are swept. + /// + public int FromCatalogVersion { get; set; } + /// Target department key version for enrollment/rotation; null for offboarding. public int? TargetKeyVersion { get; set; } diff --git a/Core/Resgrid.Model/Call.cs b/Core/Resgrid.Model/Call.cs index b3a0b721b..a7fb901c2 100644 --- a/Core/Resgrid.Model/Call.cs +++ b/Core/Resgrid.Model/Call.cs @@ -239,6 +239,13 @@ public string GetDisplayName() var number = Number?.Trim(); var name = Name?.Trim(); + // Calls.Name is cataloged; Calls.Number is deliberately not (plan 5.1). Every caller of + // this helper names a chat channel, and that name is PERSISTED and shown to the whole + // department — so a protected department must fall back to the number rather than carry + // an envelope, or the placeholder, into a durable label. + if (ProtectedDataEnvelope.HasEnvelopePrefix(name) || name == ProtectedDataEnvelope.RedactionValue) + name = null; + if (String.IsNullOrWhiteSpace(name)) return String.IsNullOrWhiteSpace(number) ? String.Empty : number; diff --git a/Core/Resgrid.Model/DepartmentDataProtectionMigrationKind.cs b/Core/Resgrid.Model/DepartmentDataProtectionMigrationKind.cs index 6abdd0325..9a5a7263b 100644 --- a/Core/Resgrid.Model/DepartmentDataProtectionMigrationKind.cs +++ b/Core/Resgrid.Model/DepartmentDataProtectionMigrationKind.cs @@ -10,6 +10,14 @@ public enum DepartmentDataProtectionMigrationKind { Enrollment = 0, Offboarding = 1, - Rotation = 2 + Rotation = 2, + + /// + /// A catalog upgrade: the code's protected-field catalog has advanced past the version this + /// department was migrated to, so the fields added since are still plaintext. The sweep + /// encrypts ONLY those fields and then stamps the department's new catalog version. + /// Existing envelopes are untouched — the catalog version is not an AAD component. + /// + CatalogUpgrade = 3 } } diff --git a/Core/Resgrid.Model/DepartmentMemberEmergencyContact.cs b/Core/Resgrid.Model/DepartmentMemberEmergencyContact.cs new file mode 100644 index 000000000..2c8bd6e74 --- /dev/null +++ b/Core/Resgrid.Model/DepartmentMemberEmergencyContact.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// One emergency contact for a member, scoped to a department (ADP plan section 5.1). A member + /// may have several, and the set is per department — UserProfile is global to the user and + /// shared across every department they belong to, so it can neither be encrypted with a single + /// department's key nor hold values that legitimately differ between departments. + /// + /// The cataloged columns here ARE encrypted for a protected department. That does not conflict + /// with the rule that member notification numbers stay plaintext: these numbers are next-of-kin + /// reference data an authorized human reads, never an outbound channel handed to an SMS or voice + /// provider. + /// + public class DepartmentMemberEmergencyContact : IEntity + { + public int DepartmentMemberEmergencyContactId { get; set; } + + public int DepartmentId { get; set; } + + public string UserId { get; set; } + + public string Name { get; set; } + + public string Relationship { get; set; } + + public string PhoneNumber { get; set; } + + public string AlternatePhoneNumber { get; set; } + + public string Email { get; set; } + + public string Notes { get; set; } + + /// The contact to try first; a member may mark at most one. + public bool IsPrimary { get; set; } + + public int SortOrder { get; set; } + + public bool IsDeleted { get; set; } + + /// True once this row's cataloged columns carry rgdp envelopes. + public bool IsProtected { get; set; } + + public DateTime CreatedOn { get; set; } + + public string CreatedByUserId { get; set; } + + public DateTime? UpdatedOn { get; set; } + + public string UpdatedByUserId { get; set; } + + public object IdValue + { + get { return DepartmentMemberEmergencyContactId; } + set { DepartmentMemberEmergencyContactId = (int)value; } + } + + public string TableName => "DepartmentMemberEmergencyContacts"; + + public string IdName => "DepartmentMemberEmergencyContactId"; + + public int IdType => 0; + + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName" }; + } +} diff --git a/Core/Resgrid.Model/DepartmentMemberSensitiveData.cs b/Core/Resgrid.Model/DepartmentMemberSensitiveData.cs index 2aed24456..526753cfe 100644 --- a/Core/Resgrid.Model/DepartmentMemberSensitiveData.cs +++ b/Core/Resgrid.Model/DepartmentMemberSensitiveData.cs @@ -29,8 +29,10 @@ public class DepartmentMemberSensitiveData : IEntity 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. + /// Stable opaque row identifier, assigned once on create. The envelope AAD binds this row by + /// its identity primary key (the row key every other protected table uses), so this column is + /// not itself an AAD component — it exists so the row can be referred to without leaking the + /// sequential key, and it is NOT NULL, so anything inserting a row must supply it. /// [Required] [MaxLength(64)] @@ -39,19 +41,47 @@ public class DepartmentMemberSensitiveData : IEntity /// 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; } + // Department-scoped member addresses (plan 5.1). Deliberately columns rather than a link to + // the shared Addresses table: that row has no owner and is reachable from contacts, + // departments and stations too, so encrypting it for one department would break the others. + public string HomeAddress1 { get; set; } + + public string HomeCity { get; set; } + + public string HomeState { get; set; } + + public string HomePostalCode { get; set; } + + public string HomeCountry { get; set; } + + public string MailingAddress1 { get; set; } + + public string MailingCity { get; set; } + + public string MailingState { get; set; } + + public string MailingPostalCode { get; set; } + + public string MailingCountry { 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; } + /// + /// When this member's legacy global-profile data (identification number and addresses) was + /// moved onto this row. Null means the move is still outstanding, which is what the + /// relocation worker sweeps for. Deliberately a marker rather than an emptiness check: a + /// member who CLEARS their department identification number must not have the legacy value + /// pushed back onto them by the next pass. + /// + public DateTime? LegacyProfileRelocatedOn { get; set; } + public DateTime CreatedOn { get; set; } public DateTime? UpdatedOn { get; set; } diff --git a/Core/Resgrid.Model/MemberProfileRelocationResult.cs b/Core/Resgrid.Model/MemberProfileRelocationResult.cs new file mode 100644 index 000000000..b984d012d --- /dev/null +++ b/Core/Resgrid.Model/MemberProfileRelocationResult.cs @@ -0,0 +1,36 @@ +using System; + +namespace Resgrid.Model +{ + /// + /// Outcome of one department's pass of the legacy member-profile relocation (ADP plan section + /// 5.1): what moved off the global UserProfiles row onto the department-scoped + /// DepartmentMemberSensitiveData row, and what could not be moved this pass. + /// + public sealed class MemberProfileRelocationResult + { + public int DepartmentId { get; set; } + + /// Members whose relocation marker was still unset when the pass started. + public int MembersExamined { get; set; } + + /// Members who had no department-scoped row until this pass created one. + public int RowsCreated { get; set; } + + public int IdentificationNumbersMoved { get; set; } + + public int AddressesMoved { get; set; } + + /// + /// Members whose move threw. Their marker stays unset, so the next pass retries them; the + /// pass itself does not fail, because one bad member must not strand a whole department. + /// + public int Failures { get; set; } + + /// True when the pass moved or marked anything at all. + public bool DidWork => MembersExamined > 0; + + public override string ToString() => + $"department {DepartmentId}: examined {MembersExamined}, created {RowsCreated}, ids {IdentificationNumbersMoved}, addresses {AddressesMoved}, failures {Failures}"; + } +} diff --git a/Core/Resgrid.Model/PersonnelCertification.cs b/Core/Resgrid.Model/PersonnelCertification.cs index 01b1fe697..c90097706 100644 --- a/Core/Resgrid.Model/PersonnelCertification.cs +++ b/Core/Resgrid.Model/PersonnelCertification.cs @@ -47,6 +47,13 @@ public class PersonnelCertification : IEntity public byte[] Data { get; set; } + /// + /// True when this row's cataloged values carry rgdp envelopes (ADP plan 5.1 Personnel family: + /// certification numbers and documents). Drives the migration cursor and the + /// double-encryption guard. + /// + public bool IsProtected { get; set; } + [NotMapped] [JsonIgnore] public object IdValue diff --git a/Core/Resgrid.Model/ProtectedEgressScanner.cs b/Core/Resgrid.Model/ProtectedEgressScanner.cs new file mode 100644 index 000000000..918bcb58e --- /dev/null +++ b/Core/Resgrid.Model/ProtectedEgressScanner.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Reflection; +using System.Text; + +namespace Resgrid.Model +{ + /// + /// Last-resort net at the response boundary: walks an outgoing model graph and replaces any + /// value still carrying an ADP envelope with the REDACTED placeholder. + /// + /// This exists because of a gap no other guard can see. The catalog proves a field is + /// protected; the binding-parity test proves a read accessor EXISTS; nothing proves a + /// controller actually calls the resolve method. Four real leaks were found by hand across the + /// MVC and v4 surfaces — serialized contact fields, raw UDF values, an enveloped call name + /// baked into a persisted chat channel name — and each was invisible to the test suite. + /// Detecting the envelope itself cannot be forgotten the way a per-surface call can. + /// + /// It is a NET, not a substitute for resolving properly: a caller holding a grant still needs + /// the resolve call to see real values, and a redaction here means a surface was missed, which + /// is why the filter logs every hit with its path. + /// + public static class ProtectedEgressScanner + { + /// Reflection is cached per type — the walk runs on a request path. + private static readonly ConcurrentDictionary PropertyCache = new(); + + /// + /// Types whose contents are never walked. Anything outside the Resgrid object model is + /// framework machinery: walking it wastes the node budget and risks touching getters with + /// side effects that have nothing to do with protected data. + /// + private static readonly HashSet ScalarTypes = new() + { + typeof(string), typeof(decimal), typeof(DateTime), typeof(DateTimeOffset), typeof(TimeSpan), + typeof(Guid), typeof(Uri), typeof(byte[]) + }; + + public sealed class EgressScanResult + { + /// Values replaced with the placeholder. + public int Redacted { get; set; } + + /// + /// Enveloped values found on read-only members, which cannot be rewritten. These still + /// leave with the response — the count exists so the log says so plainly rather than + /// implying the net caught everything. + /// + public int Unfixable { get; set; } + + /// Member paths that carried an envelope, for the log line. + public List Paths { get; } = new(); + + /// True when the node budget ran out before the graph did. + public bool Truncated { get; set; } + + public bool FoundAnything => Redacted > 0 || Unfixable > 0; + } + + /// + /// Walks and redacts in place. Bounded on both depth and node count: + /// this runs per request for a protected department, and an unbounded reflective walk over + /// an arbitrary view model is not something to put on a dispatch path. + /// + public static EgressScanResult Sanitize(object root, int maxDepth = 12, int maxNodes = 20000) + { + var result = new EgressScanResult(); + if (root == null) + return result; + + var visited = new HashSet(ReferenceEqualityComparer.Instance); + var nodes = 0; + + Walk(root, "$", 0, maxDepth, maxNodes, visited, result, ref nodes); + return result; + } + + private static void Walk(object node, string path, int depth, int maxDepth, int maxNodes, + HashSet visited, EgressScanResult result, ref int nodes) + { + if (node == null || depth > maxDepth) + return; + + if (nodes >= maxNodes) + { + result.Truncated = true; + return; + } + + nodes++; + + var type = node.GetType(); + if (type.IsPrimitive || type.IsEnum || ScalarTypes.Contains(type)) + return; + + // Reference cycles and shared child objects are both common in these graphs. + if (!visited.Add(node)) + return; + + if (node is IDictionary dictionary) + { + foreach (DictionaryEntry entry in dictionary) + { + if (TryRedactValue(entry.Value, out var replacement)) + { + // A dictionary slot can always be rewritten, unlike a read-only property. + dictionary[entry.Key] = replacement; + result.Redacted++; + result.Paths.Add($"{path}[{entry.Key}]"); + continue; + } + + Walk(entry.Value, $"{path}[{entry.Key}]", depth + 1, maxDepth, maxNodes, visited, result, ref nodes); + } + + return; + } + + if (node is IList list) + { + for (var i = 0; i < list.Count; i++) + { + if (TryRedactValue(list[i], out var replacement)) + { + list[i] = replacement; + result.Redacted++; + result.Paths.Add($"{path}[{i}]"); + continue; + } + + Walk(list[i], $"{path}[{i}]", depth + 1, maxDepth, maxNodes, visited, result, ref nodes); + } + + return; + } + + if (node is IEnumerable enumerable && !(node is string)) + { + // Read-only sequences: nothing can be rewritten in place, but the elements + // themselves may be objects whose properties can. + var index = 0; + foreach (var item in enumerable) + { + if (item != null && IsEnvelopedValue(item)) + { + result.Unfixable++; + result.Paths.Add($"{path}[{index}] (read-only sequence)"); + } + else + { + Walk(item, $"{path}[{index}]", depth + 1, maxDepth, maxNodes, visited, result, ref nodes); + } + + index++; + } + + return; + } + + if (!IsResgridType(type)) + return; + + foreach (var property in GetProperties(type)) + { + object value; + try + { + value = property.GetValue(node); + } + catch + { + // A computed getter that throws must not take the response down with it. + continue; + } + + if (value == null) + continue; + + var childPath = $"{path}.{property.Name}"; + + if (IsEnvelopedValue(value)) + { + if (!property.CanWrite) + { + result.Unfixable++; + result.Paths.Add($"{childPath} (read-only)"); + continue; + } + + try + { + property.SetValue(node, value is byte[] ? null : ProtectedDataEnvelope.RedactionValue); + result.Redacted++; + result.Paths.Add(childPath); + } + catch + { + result.Unfixable++; + result.Paths.Add($"{childPath} (not writable)"); + } + + continue; + } + + Walk(value, childPath, depth + 1, maxDepth, maxNodes, visited, result, ref nodes); + } + } + + /// + /// True for a text envelope, and for a binary envelope whose leading bytes are the rgdpb + /// marker. Binary payloads are checked by prefix rather than decoded — an attachment can be + /// megabytes. + /// + public static bool IsEnvelopedValue(object value) + { + if (value is string text) + return ProtectedDataEnvelope.HasEnvelopePrefix(text); + + if (value is byte[] bytes) + return HasBinaryEnvelopePrefix(bytes); + + return false; + } + + private static bool TryRedactValue(object value, out object replacement) + { + replacement = null; + + if (value is string text && ProtectedDataEnvelope.HasEnvelopePrefix(text)) + { + replacement = ProtectedDataEnvelope.RedactionValue; + return true; + } + + if (value is byte[] bytes && HasBinaryEnvelopePrefix(bytes)) + return true; + + return false; + } + + /// True when a byte payload starts with the raw rgdpb marker. + public static bool HasBinaryEnvelopePrefix(byte[] value) + { + if (value == null || value.Length < BinaryPrefixBytes.Length) + return false; + + for (var i = 0; i < BinaryPrefixBytes.Length; i++) + { + if (value[i] != BinaryPrefixBytes[i]) + return false; + } + + return true; + } + + private static readonly byte[] BinaryPrefixBytes = Encoding.ASCII.GetBytes(ProtectedDataEnvelope.BinaryPrefix); + + /// + /// Only Resgrid's own types are walked. A view model that happens to hold a framework + /// object (an HttpContext, a logger, a DbConnection) must not drag the walk into it. + /// + private static bool IsResgridType(Type type) + { + var ns = type.Namespace; + return ns != null && ns.StartsWith("Resgrid", StringComparison.Ordinal); + } + + private static PropertyInfo[] GetProperties(Type type) + { + return PropertyCache.GetOrAdd(type, static t => + { + var properties = new List(); + foreach (var property in t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + // Indexers cannot be read without arguments. + if (property.GetIndexParameters().Length > 0) + continue; + + if (!property.CanRead) + continue; + + properties.Add(property); + } + + return properties.ToArray(); + }); + } + + private sealed class ReferenceEqualityComparer : IEqualityComparer + { + public static readonly ReferenceEqualityComparer Instance = new(); + + public new bool Equals(object x, object y) => ReferenceEquals(x, y); + + public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); + } + } +} diff --git a/Core/Resgrid.Model/ProtectedOutboundGuard.cs b/Core/Resgrid.Model/ProtectedOutboundGuard.cs new file mode 100644 index 000000000..2373c9289 --- /dev/null +++ b/Core/Resgrid.Model/ProtectedOutboundGuard.cs @@ -0,0 +1,59 @@ +using System.Text.RegularExpressions; + +namespace Resgrid.Model +{ + /// + /// The queue-side twin of : scrubs ADP envelopes out of + /// text that is about to leave the platform through a carrier — an email body, an SMS, a push + /// title. + /// + /// Worker output does not pass through the HTTP response filter, and it is the one direction + /// where a mistake is irreversible: an email is delivered, an SMS reaches a carrier, and no + /// amount of later fixing recalls it. Every notification path is supposed to go through + /// IProtectedProjectionService first, which produces a properly worded safe message; + /// this only catches the paths that did not, and it deliberately never blocks the send. + /// A dispatch that arrives degraded still tells a responder something is happening; a dispatch + /// that never arrives could cost someone their life. + /// + public static class ProtectedOutboundGuard + { + /// + /// Matches a whole text envelope. Kept deliberately tight — "rgdp:" alone would also match + /// ordinary prose that happens to mention the format, and scrubbing a support email about + /// encryption would be its own kind of bug. + /// + private static readonly Regex EnvelopePattern = new( + @"rgdpb?:\d+:\d+:[A-Za-z0-9+/=_-]+", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// Cheap pre-check so the regex only runs on text that could possibly match. + public static bool MightContainEnvelope(string text) + { + return !string.IsNullOrEmpty(text) && + text.IndexOf(ProtectedDataEnvelope.Prefix, System.StringComparison.Ordinal) >= 0; + } + + /// + /// Replaces every envelope in with the placeholder. + /// reports how many, so the caller can log that a surface + /// skipped its projection rather than silently papering over it. + /// + public static string Scrub(string text, out int replaced) + { + replaced = 0; + + if (!MightContainEnvelope(text)) + return text; + + var count = 0; + var scrubbed = EnvelopePattern.Replace(text, _ => + { + count++; + return ProtectedDataEnvelope.RedactionValue; + }); + + replaced = count; + return scrubbed; + } + } +} diff --git a/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs b/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs index 59023eb94..77fbc3582 100644 --- a/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs +++ b/Core/Resgrid.Model/Providers/IProtectedDataBrokerClient.cs @@ -50,7 +50,12 @@ public class ProtectedFieldOperationItem /// True for rgdpb binary fields — Value is base64 in both directions. public bool IsBinary { get; set; } - /// Catalog version the envelope's AAD was bound with. + /// + /// The department's pinned catalog version at the time of the operation. Metadata and + /// forensics ONLY — it is deliberately NOT an AAD component (see + /// IProtectedFieldCryptoService). Binding it would make a department's whole stored corpus + /// undecryptable the moment its catalog version advanced. Do not feed it into AAD. + /// public int CatalogVersion { get; set; } } diff --git a/Core/Resgrid.Model/Repositories/IDepartmentMemberEmergencyContactRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentMemberEmergencyContactRepository.cs new file mode 100644 index 000000000..ac8a9ef33 --- /dev/null +++ b/Core/Resgrid.Model/Repositories/IDepartmentMemberEmergencyContactRepository.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Resgrid.Model.Repositories +{ + public interface IDepartmentMemberEmergencyContactRepository : IRepository + { + /// The member's non-deleted emergency contacts for one department, primary first. + Task> GetAllByDepartmentAndUserAsync(int departmentId, string userId); + + /// + /// Hard-deletes every emergency contact a member holds in one department, soft-deleted rows + /// included. Used when an account is deleted: a soft delete would leave the contact's name, + /// phone and email in the database, which is the opposite of what a deletion promises. + /// + Task DeleteAllByDepartmentAndUserAsync(int departmentId, string userId); + } +} diff --git a/Core/Resgrid.Model/Repositories/IDepartmentMemberSensitiveDataRepository.cs b/Core/Resgrid.Model/Repositories/IDepartmentMemberSensitiveDataRepository.cs index eed87cf18..f6a915eb3 100644 --- a/Core/Resgrid.Model/Repositories/IDepartmentMemberSensitiveDataRepository.cs +++ b/Core/Resgrid.Model/Repositories/IDepartmentMemberSensitiveDataRepository.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading.Tasks; namespace Resgrid.Model.Repositories @@ -5,5 +6,16 @@ namespace Resgrid.Model.Repositories public interface IDepartmentMemberSensitiveDataRepository : IRepository { Task GetByDepartmentAndUserAsync(int departmentId, string userId); + + /// Every member row for one department — one query for list and report screens. + Task> GetAllByDepartmentIdAsync(int departmentId); + + /// + /// Departments that still have members whose legacy global-profile data (identification + /// number or addresses) has not been moved onto a department-scoped row. Drives the + /// relocation worker, and reads zero once the move is complete — which is the precondition + /// for the contract migration that drops the legacy columns. + /// + Task> GetDepartmentIdsWithOutstandingLegacyProfileDataAsync(); } } diff --git a/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs b/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs index 3118074fc..5ed754d56 100644 --- a/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs @@ -36,6 +36,21 @@ public interface IDepartmentDataProtectionService /// Task IsProtectionEnforcedAsync(int departmentId); + /// + /// The catalog version this department is PINNED at — the version its envelopes were written + /// under and the one its AAD is computed from. Zero when the department has no policy row. + /// Never assume the code's current catalog version: a department that enrolled earlier owns + /// only the fields that existed then, until a catalog upgrade sweeps it. + /// + Task GetPinnedCatalogVersionAsync(int departmentId); + + /// + /// True when the code's catalog has advanced past what this protected department was migrated + /// to, so newly cataloged fields are still landing in plaintext and an upgrade sweep is owed. + /// False for unprotected departments (nothing to upgrade) and for departments already current. + /// + Task IsCatalogUpgradePendingAsync(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 diff --git a/Core/Resgrid.Model/Services/IDepartmentMemberEmergencyContactService.cs b/Core/Resgrid.Model/Services/IDepartmentMemberEmergencyContactService.cs new file mode 100644 index 000000000..576c0b619 --- /dev/null +++ b/Core/Resgrid.Model/Services/IDepartmentMemberEmergencyContactService.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// A member's emergency contacts, scoped to a department (ADP plan section 5.1). A member may + /// have several per department, and the values may legitimately differ between departments. + /// Cataloged columns are enveloped by the write safety net, so callers pass and receive + /// plaintext (or the REDACTED placeholder when no grant is held) and never handle envelopes. + /// + public interface IDepartmentMemberEmergencyContactService + { + /// The member's contacts for one department, primary first. Never null. + Task> GetAllForMemberAsync(int departmentId, string userId); + + /// Creates or updates one contact. + Task SaveAsync(DepartmentMemberEmergencyContact contact, + CancellationToken cancellationToken = default); + + /// + /// Soft-deletes one contact. Scoped by department and user so a caller cannot remove another + /// member's row by id alone. + /// + /// + /// Removes every emergency contact a member holds in one department. Used when an account is + /// deleted — these rows carry the contact's name, phone and email, which is third-party + /// personal data that must not outlive the member's account. + /// + Task DeleteAllForMemberAsync(int departmentId, string userId, + CancellationToken cancellationToken = default); + + Task DeleteAsync(int departmentMemberEmergencyContactId, int departmentId, string userId, + string deletingUserId, CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IDepartmentMemberSensitiveDataService.cs b/Core/Resgrid.Model/Services/IDepartmentMemberSensitiveDataService.cs new file mode 100644 index 000000000..ca5f1a614 --- /dev/null +++ b/Core/Resgrid.Model/Services/IDepartmentMemberSensitiveDataService.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Department-scoped sensitive personnel attributes (ADP plan section 5.1). These live here + /// rather than on for a structural reason: a profile row is GLOBAL to + /// the user and shared across every department they belong to, so it cannot be encrypted with + /// any one department's key. This table is keyed (DepartmentId, UserId), which is what makes + /// per-department protection possible at all. + /// + public interface IDepartmentMemberSensitiveDataService + { + /// The row for one member of one department, or null when none has been created. + Task GetByDepartmentAndUserAsync(int departmentId, string userId); + + /// + /// Creates or updates the member's row. The cataloged columns are enveloped by the write + /// safety net before the row is persisted, so callers pass plaintext and never deal with + /// envelopes themselves. + /// + /// + /// Removes a member's department-scoped row outright. Used when an account is deleted: this + /// row is now the ONLY copy of their identification number and address for this department, + /// so leaving it behind would retain personal data the deletion is supposed to remove. + /// + Task DeleteForMemberAsync(int departmentId, string userId, + CancellationToken cancellationToken = default); + + Task SaveAsync(DepartmentMemberSensitiveData data, + CancellationToken cancellationToken = default); + + /// + /// Stamps each profile's with the value this + /// department holds for that member, resolved through the protected-read pipeline. The + /// number is department-issued, so the profile's own (global, legacy) column is never the + /// answer once a department row exists — a member with no row for this department simply has + /// no number here. One query and one resolve for the whole list. + /// + /// + /// Every member's department-scoped row for one department, keyed by user id and already put + /// through the protected read pipeline — so a protected department hands back the REDACTED + /// placeholder wherever the caller has no grant, never ciphertext. + /// + Task> GetResolvedForDepartmentAsync( + int departmentId, string grantToken, string actingUserId); + + Task ApplyIdentificationNumbersAsync(int departmentId, IEnumerable profiles, + string grantToken, string actingUserId); + } +} diff --git a/Core/Resgrid.Model/Services/IMemberProfileRelocationService.cs b/Core/Resgrid.Model/Services/IMemberProfileRelocationService.cs new file mode 100644 index 000000000..1edb8ed4b --- /dev/null +++ b/Core/Resgrid.Model/Services/IMemberProfileRelocationService.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Moves a member's legacy global-profile data — identification number and home/mailing address + /// — onto their department-scoped DepartmentMemberSensitiveData row (ADP plan section 5.1). + /// + /// M0134 does the bulk of this in SQL at deploy time, but it can only touch rows that are still + /// plaintext: writing a cleartext address into an enrolled department's row would poison it. + /// This service is the path for everything SQL cannot do — departments already enrolled in ADP + /// (the move goes through the ADP write pipeline and is enveloped as it lands), departments that + /// enroll later (relocation runs as the first step of the encryption night, so nothing is left + /// behind in the legacy location), and members who join after the migration ran. + /// + /// Every pass is idempotent and non-destructive: a target field that already holds a value — + /// plaintext or ciphertext — is never overwritten, and a member is marked relocated exactly once. + /// + public interface IMemberProfileRelocationService + { + /// + /// Departments with at least one member whose legacy data has not been relocated. Reads empty + /// once the move is complete, which is the precondition for the contract migration that drops + /// the legacy columns. + /// + Task> GetDepartmentIdsWithOutstandingDataAsync(); + + /// + /// Relocates every unmarked member of one department. Safe to call at any point in the ADP + /// state machine: values land through the normal write path, so they are enveloped whenever + /// the department is encrypting new writes, regardless of where a migration cursor sits. + /// + Task RelocateDepartmentAsync(int departmentId, + CancellationToken cancellationToken = default); + } +} diff --git a/Core/Resgrid.Model/Services/IProtectedFieldCatalog.cs b/Core/Resgrid.Model/Services/IProtectedFieldCatalog.cs index 73806ef56..cb1135354 100644 --- a/Core/Resgrid.Model/Services/IProtectedFieldCatalog.cs +++ b/Core/Resgrid.Model/Services/IProtectedFieldCatalog.cs @@ -13,12 +13,31 @@ public interface IProtectedFieldCatalog /// Current catalog version. Incremented whenever entries are added. int Version { get; } - /// Every catalog entry. + /// Every catalog entry, at the CURRENT catalog version. IReadOnlyList GetAll(); + /// + /// Every entry a department pinned at owns — that is, every + /// entry whose AddedInCatalogVersion is at or below it. A department that enrolled under an + /// older catalog must NOT start encrypting fields added later: its policy still records the + /// old version, its AAD is computed from that version, and the new fields have never been + /// swept. They become its rows only when a catalog upgrade runs for that department. + /// + IReadOnlyList GetAllForVersion(int catalogVersion); + /// Entries for one physical table (SQL Server casing; lookup is case-insensitive). Empty when none. IReadOnlyList GetForTable(string tableName); + /// Entries for one physical table that a department at owns. + IReadOnlyList GetForTableAndVersion(string tableName, int catalogVersion); + + /// + /// Entries added strictly after and at or below + /// — exactly the fields a catalog-upgrade sweep must + /// encrypt for a department moving between those versions. + /// + IReadOnlyList GetAddedBetween(int fromCatalogVersion, int toCatalogVersion); + /// The entry with the given stable field id, or null. ProtectedFieldDefinition GetById(string fieldId); diff --git a/Core/Resgrid.Model/Services/IProtectedFieldCryptoService.cs b/Core/Resgrid.Model/Services/IProtectedFieldCryptoService.cs index faefdea65..044f105be 100644 --- a/Core/Resgrid.Model/Services/IProtectedFieldCryptoService.cs +++ b/Core/Resgrid.Model/Services/IProtectedFieldCryptoService.cs @@ -4,9 +4,15 @@ 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. + /// DepartmentId, the stable catalog field id, the stable per-row key, and the envelope format + /// version — moving ciphertext between tenants, rows, or fields fails authentication rather + /// than decrypting. + /// + /// The CATALOG version is deliberately NOT an AAD component. Field ids are stable forever, so + /// the catalog version adds no binding the field id does not already provide — while including + /// it would make a department's whole corpus undecryptable the moment its pinned catalog + /// version advanced, turning every catalog addition into a full decrypt/re-encrypt of every + /// protected row. Do not add it back. /// public interface IProtectedFieldCryptoService { @@ -16,22 +22,22 @@ public interface IProtectedFieldCryptoService /// 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); + int departmentId, string catalogFieldId, string rowKey); /// /// 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); + int departmentId, string catalogFieldId, string rowKey); /// 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); + int departmentId, string catalogFieldId, string rowKey); /// 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); + int departmentId, string catalogFieldId, string rowKey); /// True when the blob starts with the rgdpb binary envelope header. bool IsBinaryEnveloped(byte[] value); diff --git a/Core/Resgrid.Model/Services/IProtectedReadService.cs b/Core/Resgrid.Model/Services/IProtectedReadService.cs index e15158fbb..5affe4001 100644 --- a/Core/Resgrid.Model/Services/IProtectedReadService.cs +++ b/Core/Resgrid.Model/Services/IProtectedReadService.cs @@ -55,8 +55,54 @@ Task ResolveAttachmentsForReadAsync(int departmentId, Task ResolveContactsForReadAsync(int departmentId, IReadOnlyList contacts, string grantToken, string userId, CancellationToken cancellationToken = default); + /// + /// Resolves personnel certification batches (plan 5.1 Personnel family). includeData + /// additionally decrypts the rgdpb document payload — only the file-serving endpoint opts in; + /// everywhere else the bytes are stripped to null rather than carried out as ciphertext. + /// + Task ResolveCertificationsForReadAsync(int departmentId, + IReadOnlyList certifications, string grantToken, string userId, + bool includeData = false, CancellationToken cancellationToken = default); + /// Resolves standalone contact-note lists (contactnotes.note). Task ResolveContactNotesForReadAsync(int departmentId, IReadOnlyList notes, string grantToken, string userId, CancellationToken cancellationToken = default); + + /// Resolves a member's department-scoped emergency contacts (catalog v4). + Task ResolveMemberEmergencyContactsForReadAsync(int departmentId, + IReadOnlyList contacts, string grantToken, string userId, + CancellationToken cancellationToken = default); + + /// Resolves department-scoped sensitive personnel rows (catalog v1 personnel family). + Task ResolveMemberSensitiveDataForReadAsync(int departmentId, + IReadOnlyList rows, string grantToken, string userId, CancellationToken cancellationToken = default); + + /// + /// Resolves incident logs (catalog v3): narrative, initial report, cause, contact details, + /// other personnel, location, and the body/pronounced-deceased fields. + /// + /// + /// Resolves call-log narratives (calllogs.narrative). A separate table and entity from the + /// Log family: these are the per-call running log entries, not incident work logs. + /// + Task ResolveCallLogsForReadAsync(int departmentId, + IReadOnlyList logs, string grantToken, string userId, CancellationToken cancellationToken = default); + + Task ResolveLogsForReadAsync(int departmentId, + IReadOnlyList logs, string grantToken, string userId, CancellationToken cancellationToken = default); + + /// + /// Resolves unit states (catalog v2 operational family): the crew's note, the position the + /// state was filed from, and the latitude/longitude companion envelopes. + /// + Task ResolveUnitStatesForReadAsync(int departmentId, + IReadOnlyList states, string grantToken, string userId, CancellationToken cancellationToken = default); + + /// + /// Resolves user-defined field values (catalog v2 operational family). UDF values are + /// free text on any entity, so a protected department treats them all as sensitive. + /// + Task ResolveUdfFieldValuesForReadAsync(int departmentId, + IReadOnlyList values, string grantToken, string userId, CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Model/Services/IProtectedWriteService.cs b/Core/Resgrid.Model/Services/IProtectedWriteService.cs index 89363f272..4b547470a 100644 --- a/Core/Resgrid.Model/Services/IProtectedWriteService.cs +++ b/Core/Resgrid.Model/Services/IProtectedWriteService.cs @@ -57,8 +57,46 @@ Task PrepareCallAttachmentWriteAsync(int departmentId, Cal Task PrepareContactWriteAsync(int departmentId, Contact contact, Contact existingContact, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + /// + /// Prepares a personnel certification (six cataloged text fields plus the binary document). + /// existingCertification enables REDACTED-sentinel restoration on edits, so an admin editing + /// a member's certification without a grant cannot save the placeholder over the real value; + /// pass null for creates. + /// + Task PrepareCertificationWriteAsync(int departmentId, + PersonnelCertification certification, PersonnelCertification existingCertification, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares a call log narrative (calllogs.narrative). + Task PrepareCallLogWriteAsync(int departmentId, CallLog log, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + /// Prepares a contact note (text field). Task PrepareContactNoteWriteAsync(int departmentId, ContactNote note, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// + /// Prepares a unit state (catalog v2): note, geolocation text, and the typed + /// latitude/longitude which move into their companion envelope columns. + /// + Task PrepareUnitStateWriteAsync(int departmentId, UnitState state, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares one department-scoped emergency contact (catalog v4). + Task PrepareMemberEmergencyContactWriteAsync(int departmentId, + DepartmentMemberEmergencyContact contact, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default); + + /// Prepares a department-scoped sensitive personnel row (catalog v1 personnel family). + Task PrepareMemberSensitiveDataWriteAsync(int departmentId, DepartmentMemberSensitiveData data, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares an incident log (catalog v3). + Task PrepareLogWriteAsync(int departmentId, Log log, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares a user-defined field value (catalog v2). + Task PrepareUdfFieldValueWriteAsync(int departmentId, UdfFieldValue value, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); } } diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index 125f9a6fe..b2c9cc437 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using System.Linq; using Resgrid.Model; +using Resgrid.Model.Services; namespace Resgrid.Services { @@ -14,6 +16,39 @@ public static class AdpTableBindings { public static readonly IReadOnlyList V1 = Build(); + /// + /// The bindings restricted to the columns whose catalog fields were added in + /// (fromCatalogVersion, toCatalogVersion] — the exact work list for a catalog-upgrade sweep. + /// Tables left with no in-range column are dropped entirely, so an upgrade never re-reads a + /// table it has nothing to do in. A range covering everything returns the full bindings. + /// + public static IReadOnlyList ForVersionRange(IProtectedFieldCatalog catalog, + int fromCatalogVersion, int toCatalogVersion) + { + if (catalog == null || toCatalogVersion <= fromCatalogVersion) + return new List(); + + var inRange = new HashSet( + catalog.GetAddedBetween(fromCatalogVersion, toCatalogVersion).Select(e => e.FieldId), + System.StringComparer.OrdinalIgnoreCase); + + var scoped = new List(); + foreach (var binding in V1) + { + var columns = binding.Columns.Where(c => inRange.Contains(c.FieldId)).ToList(); + if (columns.Count == 0) + continue; + + // Columns is constructor-only, so rebuild the binding with the in-range subset and + // carry the init-only marker column across. + scoped.Add(new AdpTableBinding(binding.TableName, binding.PkColumn, binding.PkIsNumeric, + binding.DepartmentColumn, binding.ParentFkColumn, binding.ParentTable, binding.ParentPkColumn, + columns) with { ProtectedMarkerColumn = binding.ProtectedMarkerColumn }); + } + + return scoped; + } + private static IReadOnlyList Build() { AdpColumnSpec Text(string table, string column) => @@ -77,12 +112,74 @@ AdpColumnSpec Companion(string table, string column) => Text("ContactNotes", "Note") }), + // Catalog v2 (section 5.2). Neither table carries its own DepartmentId, so ownership + // derives from a verified parent: UDF values through their definition, unit states + // through the unit. MessageRecipients is deliberately ABSENT — Messages has no + // DepartmentId either, so it needs the section 5.1 child-table ownership migration + // before it can be bound at all. + // Catalog v3: the incident log carries its own DepartmentId. + AdpTableBinding.Direct("Logs", "LogId", pkIsNumeric: true, "DepartmentId", new[] + { + Text("Logs", "Narrative"), + Text("Logs", "InitialReport"), + Text("Logs", "Cause"), + Text("Logs", "ContactName"), + Text("Logs", "ContactNumber"), + Text("Logs", "OtherPersonnel"), + Text("Logs", "Location"), + Text("Logs", "BodyLocation"), + Text("Logs", "PronouncedDeceasedBy") + }), + + AdpTableBinding.ViaParent("UdfFieldValues", "UdfFieldValueId", pkIsNumeric: false, "UdfDefinitionId", "UdfDefinitions", "UdfDefinitionId", new[] + { + Text("UdfFieldValues", "Value") + }), + + AdpTableBinding.ViaParent("UnitStates", "UnitStateId", pkIsNumeric: true, "UnitId", "Units", "UnitId", new[] + { + Text("UnitStates", "Note"), + Text("UnitStates", "GeoLocationData"), + Companion("UnitStates", "Latitude"), + Companion("UnitStates", "Longitude") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + // Catalog v4: a member's department-scoped emergency contacts (several per member). + AdpTableBinding.Direct("DepartmentMemberEmergencyContacts", "DepartmentMemberEmergencyContactId", pkIsNumeric: true, "DepartmentId", new[] + { + Text("DepartmentMemberEmergencyContacts", "Name"), + Text("DepartmentMemberEmergencyContacts", "Relationship"), + Text("DepartmentMemberEmergencyContacts", "PhoneNumber"), + Text("DepartmentMemberEmergencyContacts", "AlternatePhoneNumber"), + Text("DepartmentMemberEmergencyContacts", "Email"), + Text("DepartmentMemberEmergencyContacts", "Notes") + }) with { ProtectedMarkerColumn = "IsProtected" }, + AdpTableBinding.Direct("DepartmentMemberSensitiveData", "DepartmentMemberSensitiveDataId", pkIsNumeric: true, "DepartmentId", new[] { Text("DepartmentMemberSensitiveData", "IdentificationNumber"), - Text("DepartmentMemberSensitiveData", "EmergencyContactName"), - Text("DepartmentMemberSensitiveData", "EmergencyContactPhone"), - Text("DepartmentMemberSensitiveData", "Notes") + Text("DepartmentMemberSensitiveData", "Notes"), + Text("DepartmentMemberSensitiveData", "HomeAddress1"), + Text("DepartmentMemberSensitiveData", "HomeCity"), + Text("DepartmentMemberSensitiveData", "HomeState"), + Text("DepartmentMemberSensitiveData", "HomePostalCode"), + Text("DepartmentMemberSensitiveData", "HomeCountry"), + Text("DepartmentMemberSensitiveData", "MailingAddress1"), + Text("DepartmentMemberSensitiveData", "MailingCity"), + Text("DepartmentMemberSensitiveData", "MailingState"), + Text("DepartmentMemberSensitiveData", "MailingPostalCode"), + Text("DepartmentMemberSensitiveData", "MailingCountry") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("PersonnelCertifications", "PersonnelCertificationId", pkIsNumeric: true, "DepartmentId", new[] + { + Text("PersonnelCertifications", "Name"), + Text("PersonnelCertifications", "Number"), + Text("PersonnelCertifications", "Type"), + Text("PersonnelCertifications", "Area"), + Text("PersonnelCertifications", "IssuedBy"), + Text("PersonnelCertifications", "Filename"), + Binary("PersonnelCertifications", "Data") }) with { ProtectedMarkerColumn = "IsProtected" } }; } diff --git a/Core/Resgrid.Services/CallsService.cs b/Core/Resgrid.Services/CallsService.cs index 5a5d9827a..3add0a6e7 100644 --- a/Core/Resgrid.Services/CallsService.cs +++ b/Core/Resgrid.Services/CallsService.cs @@ -452,23 +452,67 @@ public async Task GenerateCallFromEmail(int type, CallEmail email, string public async Task SaveCallNoteAsync(CallNote note, CancellationToken cancellationToken = default(CancellationToken)) { + var existingNoteForRestore = await GetStoredNoteForSentinelRestoreAsync(note); + var saved = await _callNotesRepository.SaveOrUpdateAsync(note, cancellationToken); - // ADP write safety net — see SaveCallAsync. The department comes through the parent call. + // ADP write safety net — see SaveCallAsync. The department comes through the parent call, + // so an unresolvable parent means an unknown protection state: fail closed rather than + // leaving cataloged plaintext at rest while reporting success. var call = await GetCallByIdAsync(saved.CallId); - if (call != null) + if (call == null) { - var protectedWrite = await _protectedWriteService.Value.PrepareCallNoteWriteAsync(call.DepartmentId, - saved, null, null, workloadCaller: true, cancellationToken); - if (!protectedWrite.Success) - throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); call note {saved.CallNoteId} has transient plaintext pending re-encryption."); - if (protectedWrite.Changed) - saved = await _callNotesRepository.SaveOrUpdateAsync(saved, cancellationToken); + Logging.LogError($"ADP write safety net could not resolve parent call {saved.CallId} for call note {saved.CallNoteId}; blocking the write."); + throw new InvalidOperationException($"Protected write blocked (parent call {saved.CallId} not found); call note {saved.CallNoteId} has transient plaintext pending re-encryption."); } + var restored = RestoreNoteSentinels(saved, existingNoteForRestore); + + var protectedWrite = await _protectedWriteService.Value.PrepareCallNoteWriteAsync(call.DepartmentId, + saved, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); call note {saved.CallNoteId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed || restored) + saved = await _callNotesRepository.SaveOrUpdateAsync(saved, cancellationToken); + return saved; } + /// + /// A round-tripped REDACTED placeholder on an edit means "unchanged" (the client never saw + /// the plaintext). The stored row is fetched BEFORE the save so the placeholder can be + /// replaced with the stored envelope — persisting the literal sentinel would destroy the + /// value. Returns null when no cataloged field carries the sentinel (the common case). + /// + private async Task GetStoredNoteForSentinelRestoreAsync(CallNote note) + { + if (note == null || note.CallNoteId <= 0) + return null; + + if (!ProtectedReadService.NoteFieldAccessors.Any(a => a.Value.Get(note) == ProtectedDataEnvelope.RedactionValue)) + return null; + + return await _callNotesRepository.GetByIdAsync(note.CallNoteId); + } + + private static bool RestoreNoteSentinels(CallNote note, CallNote stored) + { + if (note == null || stored == null) + return false; + + var restored = false; + foreach (var accessor in ProtectedReadService.NoteFieldAccessors) + { + if (accessor.Value.Get(note) != ProtectedDataEnvelope.RedactionValue) + continue; + + accessor.Value.Set(note, accessor.Value.Get(stored)); + restored = true; + } + + return restored; + } + public async Task> GetFlaggedCallNotesByDepartmentIdAsync(int departmentId) { var notes = await _callNotesRepository.GetFlaggedCallNotesByDepartmentIdAsync(departmentId); @@ -511,23 +555,61 @@ public async Task GetCallAttachmentAsync(int callAttachmentId) public async Task SaveCallAttachmentAsync(CallAttachment attachment, CancellationToken cancellationToken = default(CancellationToken)) { + var existingAttachmentForRestore = await GetStoredAttachmentForSentinelRestoreAsync(attachment); + var saved = await _callAttachmentRepository.SaveOrUpdateAsync(attachment, cancellationToken); - // ADP write safety net — see SaveCallAsync. + // ADP write safety net — see SaveCallAsync. Fail closed when the parent call (and so the + // department's protection state) cannot be resolved. var call = await GetCallByIdAsync(saved.CallId); - if (call != null) + if (call == null) { - var protectedWrite = await _protectedWriteService.Value.PrepareCallAttachmentWriteAsync(call.DepartmentId, - saved, null, null, workloadCaller: true, cancellationToken); - if (!protectedWrite.Success) - throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); call attachment {saved.CallAttachmentId} has transient plaintext pending re-encryption."); - if (protectedWrite.Changed) - saved = await _callAttachmentRepository.SaveOrUpdateAsync(saved, cancellationToken); + Logging.LogError($"ADP write safety net could not resolve parent call {saved.CallId} for call attachment {saved.CallAttachmentId}; blocking the write."); + throw new InvalidOperationException($"Protected write blocked (parent call {saved.CallId} not found); call attachment {saved.CallAttachmentId} has transient plaintext pending re-encryption."); } + var restored = RestoreAttachmentSentinels(saved, existingAttachmentForRestore); + + var protectedWrite = await _protectedWriteService.Value.PrepareCallAttachmentWriteAsync(call.DepartmentId, + saved, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); call attachment {saved.CallAttachmentId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed || restored) + saved = await _callAttachmentRepository.SaveOrUpdateAsync(saved, cancellationToken); + return saved; } + /// See — same contract for attachments. + private async Task GetStoredAttachmentForSentinelRestoreAsync(CallAttachment attachment) + { + if (attachment == null || attachment.CallAttachmentId <= 0) + return null; + + if (!ProtectedReadService.AttachmentFieldAccessors.Any(a => a.Value.Get(attachment) == ProtectedDataEnvelope.RedactionValue)) + return null; + + return await _callAttachmentRepository.GetByIdAsync(attachment.CallAttachmentId); + } + + private static bool RestoreAttachmentSentinels(CallAttachment attachment, CallAttachment stored) + { + if (attachment == null || stored == null) + return false; + + var restored = false; + foreach (var accessor in ProtectedReadService.AttachmentFieldAccessors) + { + if (accessor.Value.Get(attachment) != ProtectedDataEnvelope.RedactionValue) + continue; + + accessor.Value.Set(attachment, accessor.Value.Get(stored)); + restored = true; + } + + return restored; + } + public async Task MarkCallDispatchesAsSentAsync(int callId, List usersToMark) { return await _callDispatchesRepository.MarkCallDispatchesAsSentByCallIdUsersAsync(callId, usersToMark); diff --git a/Core/Resgrid.Services/CertificationService.cs b/Core/Resgrid.Services/CertificationService.cs index 44159a009..404e3bf52 100644 --- a/Core/Resgrid.Services/CertificationService.cs +++ b/Core/Resgrid.Services/CertificationService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -14,10 +15,16 @@ public class CertificationService : ICertificationService private readonly IDepartmentCertificationTypeRepository _departmentCertificationTypeRepository; private readonly IPersonnelCertificationRepository _personnelCertificationRepository; - public CertificationService(IDepartmentCertificationTypeRepository departmentCertificationTypeRepository, IPersonnelCertificationRepository personnelCertificationRepository) + // Lazy: defers the protected graph (broker client) until a save actually needs it. + private readonly Lazy _protectedWriteService; + + public CertificationService(IDepartmentCertificationTypeRepository departmentCertificationTypeRepository, + IPersonnelCertificationRepository personnelCertificationRepository, + Lazy protectedWriteService) { _departmentCertificationTypeRepository = departmentCertificationTypeRepository; _personnelCertificationRepository = personnelCertificationRepository; + _protectedWriteService = protectedWriteService; } public async Task> GetAllCertificationTypesByDepartmentAsync(int departmentId) @@ -84,7 +91,27 @@ public async Task> GetDepartmentCertificationTypesAsync(int departm public async Task SaveCertificationAsync(PersonnelCertification certification, CancellationToken cancellationToken = default(CancellationToken)) { - return await _personnelCertificationRepository.SaveOrUpdateAsync(certification, cancellationToken); + // The stored row backs REDACTED-sentinel restoration: an admin who edits a member's + // certification without a grant posts back placeholders, and those must not be written + // over the real values. Fetched before the save, while the id still identifies the + // stored row rather than the incoming one. + PersonnelCertification existing = null; + if (certification != null && certification.PersonnelCertificationId > 0) + existing = await _personnelCertificationRepository.GetByIdAsync(certification.PersonnelCertificationId); + + var saved = await _personnelCertificationRepository.SaveOrUpdateAsync(certification, cancellationToken); + + // ADP write safety net (plan 4.2/19.2). Runs AFTER the save so the identity pk exists — + // it is the AAD row key — then re-persists the enveloped row. Fails closed by throwing + // rather than leaving a member's certification number and document in plaintext. + var protectedWrite = await _protectedWriteService.Value.PrepareCertificationWriteAsync( + saved.DepartmentId, saved, existing, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); certification {saved.PersonnelCertificationId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _personnelCertificationRepository.SaveOrUpdateAsync(saved, cancellationToken); + + return saved; } public async Task GetCertificationByIdAsync(int certificationId) diff --git a/Core/Resgrid.Services/CheckInTimerService.cs b/Core/Resgrid.Services/CheckInTimerService.cs index 203bb5a1c..a8b011f34 100644 --- a/Core/Resgrid.Services/CheckInTimerService.cs +++ b/Core/Resgrid.Services/CheckInTimerService.cs @@ -657,7 +657,7 @@ public async Task> GetUserActiveCallCheckInSummarie summaries.Add(new UserCallCheckInSummary { CallId = call.CallId, - CallName = call.Name, + CallName = ProtectedDataEnvelope.SafeDisplay(call.Name), CallNumber = call.Number, CallStartedOn = call.LoggedOn, HasPersonnelTimer = false, @@ -693,7 +693,7 @@ public async Task> GetUserActiveCallCheckInSummarie summaries.Add(new UserCallCheckInSummary { CallId = call.CallId, - CallName = call.Name, + CallName = ProtectedDataEnvelope.SafeDisplay(call.Name), CallNumber = call.Number, CallStartedOn = call.LoggedOn, HasPersonnelTimer = true, diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs index 33505e5df..b404b5c95 100644 --- a/Core/Resgrid.Services/DeleteService.cs +++ b/Core/Resgrid.Services/DeleteService.cs @@ -40,6 +40,8 @@ public class DeleteService : IDeleteService private readonly IAuditLogsRepository _auditLogsRepository; private readonly IScheduledTasksService _scheduledTasksService; private readonly IUserSessionService _userSessionService; + private readonly IDepartmentMemberSensitiveDataService _memberSensitiveDataService; + private readonly IDepartmentMemberEmergencyContactService _emergencyContactService; public DeleteService(IAuthorizationService authorizationService, IDepartmentsService departmentsService, ICallsService callsService, IActionLogsService actionLogsService, IUsersService usersService, @@ -49,7 +51,9 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer ICertificationService certificationService, ILogService logService, IInventoryService inventoryService, IEventAggregator eventAggregator, IAddressService addressService, IQueueService queueService, IEmailService emailService, IDeleteRepository deleteRepository, IAuditLogsRepository auditLogsRepository, - IScheduledTasksService scheduledTasksService, IUserSessionService userSessionService) + IScheduledTasksService scheduledTasksService, IUserSessionService userSessionService, + IDepartmentMemberSensitiveDataService memberSensitiveDataService, + IDepartmentMemberEmergencyContactService emergencyContactService) { _authorizationService = authorizationService; _departmentsService = departmentsService; @@ -76,6 +80,8 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer _auditLogsRepository = auditLogsRepository; _scheduledTasksService = scheduledTasksService; _userSessionService = userSessionService; + _memberSensitiveDataService = memberSensitiveDataService; + _emergencyContactService = emergencyContactService; } public async Task DeleteUserAsync(int departmentId, string authorizingUserId, string userIdToDelete, CancellationToken cancellationToken = default(CancellationToken)) @@ -168,6 +174,13 @@ private async Task DeactivateUserAccountCoreAsync(string user { await _personnelRolesService.RemoveUserFromAllRolesAsync(userIdToDelete, dm.DepartmentId, cancellationToken); await _departmentGroupsService.DeleteUserFromGroupsAsync(userIdToDelete, dm.DepartmentId, cancellationToken); + + // Department-scoped personal data (ADP plan 5.1). Since the identification number + // and addresses moved off the global profile, these rows are the only copy — the + // profile scrub below no longer reaches them, and neither does deleting the + // legacy Addresses rows. + await _memberSensitiveDataService.DeleteForMemberAsync(dm.DepartmentId, userIdToDelete, cancellationToken); + await _emergencyContactService.DeleteAllForMemberAsync(dm.DepartmentId, userIdToDelete, cancellationToken); } } diff --git a/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs b/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs index 5cec23351..fae8c2dae 100644 --- a/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs +++ b/Core/Resgrid.Services/DepartmentDataMigrationEngine.cs @@ -29,21 +29,38 @@ public class DepartmentDataMigrationEngine : IDepartmentDataMigrationEngine { private static readonly IReadOnlyList Bindings = AdpTableBindings.V1; + /// + /// The tables and columns THIS run touches. Enrollment, rotation and offboarding sweep the + /// whole catalog; a CatalogUpgrade sweeps only the fields added since the department's pinned + /// version, so it never re-reads a table it has nothing to do in and never re-encrypts an + /// already-protected value. + /// + private IReadOnlyList BindingsFor(AdpMigrationNightContext context) + { + if (context.Kind != DepartmentDataProtectionMigrationKind.CatalogUpgrade) + return Bindings; + + return AdpTableBindings.ForVersionRange(_fieldCatalog, context.FromCatalogVersion, context.CatalogVersion); + } + private readonly IDepartmentDataProtectionBulkRepository _bulkRepository; private readonly IDepartmentDataProtectionMigrationRepository _migrationRepository; private readonly IDepartmentKeyService _keyService; private readonly IKeyWrappingProvider _keyWrappingProvider; private readonly IProtectedFieldCryptoService _cryptoService; + private readonly IProtectedFieldCatalog _fieldCatalog; public DepartmentDataMigrationEngine(IDepartmentDataProtectionBulkRepository bulkRepository, IDepartmentDataProtectionMigrationRepository migrationRepository, IDepartmentKeyService keyService, - IKeyWrappingProvider keyWrappingProvider, IProtectedFieldCryptoService cryptoService) + IKeyWrappingProvider keyWrappingProvider, IProtectedFieldCryptoService cryptoService, + IProtectedFieldCatalog fieldCatalog) { _bulkRepository = bulkRepository; _migrationRepository = migrationRepository; _keyService = keyService; _keyWrappingProvider = keyWrappingProvider; _cryptoService = cryptoService; + _fieldCatalog = fieldCatalog; } /// @@ -150,7 +167,7 @@ public async Task VerifyAsync(AdpMigrationNightContext context, Cancellati { var enveloped = context.Kind == DepartmentDataProtectionMigrationKind.Offboarding; - foreach (var binding in Bindings) + foreach (var binding in BindingsFor(context)) { cancellationToken.ThrowIfCancellationRequested(); @@ -178,7 +195,7 @@ private async Task RunNightAsync(AdpMigrationNightConte long nightProcessed = 0; var batchSize = Math.Max(50, Config.DataProtectionConfig.MigrationBatchSize); - foreach (var binding in Bindings) + foreach (var binding in BindingsFor(context)) { var migrationRow = await _migrationRepository.GetActiveByDepartmentAndTableAsync(context.DepartmentId, context.Kind, binding.TableName); @@ -308,12 +325,12 @@ async Task ValidationDekForTextAsync(string envelope) // Validate against THIS department's AAD with the key version that wrote the // envelope; a mismatch throws (foreign envelope). var validationDek = await ValidationDekForTextAsync(value); - _cryptoService.DecryptText(validationDek, value, context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion); + _cryptoService.DecryptText(validationDek, value, context.DepartmentId, spec.FieldId, row.RowKey); return ColumnOutcome.AlreadyInTargetState; } setValues[spec.ColumnName] = _cryptoService.EncryptText(targetDek, keyVersion, value, - context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion); + context.DepartmentId, spec.FieldId, row.RowKey); return ColumnOutcome.Changed; } @@ -332,12 +349,12 @@ async Task ValidationDekForTextAsync(string envelope) if (validationDek == null) throw new CryptographicException("Envelope references an unknown department key version."); - _cryptoService.DecryptBinary(validationDek, value, context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion); + _cryptoService.DecryptBinary(validationDek, value, context.DepartmentId, spec.FieldId, row.RowKey); return ColumnOutcome.AlreadyInTargetState; } setValues[spec.ColumnName] = _cryptoService.EncryptBinary(targetDek, keyVersion, value, - context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion); + context.DepartmentId, spec.FieldId, row.RowKey); return ColumnOutcome.Changed; } @@ -350,7 +367,7 @@ async Task ValidationDekForTextAsync(string envelope) { var invariant = Convert.ToString(typed, CultureInfo.InvariantCulture); setValues[spec.CompanionColumn] = _cryptoService.EncryptText(targetDek, keyVersion, invariant, - context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion); + context.DepartmentId, spec.FieldId, row.RowKey); setValues[spec.ColumnName] = null; return ColumnOutcome.Changed; } @@ -358,7 +375,7 @@ async Task ValidationDekForTextAsync(string envelope) if (!string.IsNullOrEmpty(companion)) { var validationDek = await ValidationDekForTextAsync(companion); - _cryptoService.DecryptText(validationDek, companion, context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion); + _cryptoService.DecryptText(validationDek, companion, context.DepartmentId, spec.FieldId, row.RowKey); return ColumnOutcome.AlreadyInTargetState; } @@ -395,7 +412,7 @@ private async Task DecryptRowColumnAsync(Func> 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); + context.DepartmentId, spec.FieldId, row.RowKey); return ColumnOutcome.Changed; } @@ -415,7 +432,7 @@ private async Task DecryptRowColumnAsync(Func> 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); + context.DepartmentId, spec.FieldId, row.RowKey); return ColumnOutcome.Changed; } @@ -433,7 +450,7 @@ private async Task DecryptRowColumnAsync(Func> throw new InvalidOperationException($"No key row for envelope version {envelopeKeyVersion}."); var plaintext = _cryptoService.DecryptText(dek, companion, - context.DepartmentId, spec.FieldId, row.RowKey, context.CatalogVersion); + context.DepartmentId, spec.FieldId, row.RowKey); setValues[spec.ColumnName] = decimal.Parse(plaintext, CultureInfo.InvariantCulture); setValues[spec.CompanionColumn] = null; return ColumnOutcome.Changed; diff --git a/Core/Resgrid.Services/DepartmentDataProtectionService.cs b/Core/Resgrid.Services/DepartmentDataProtectionService.cs index 5449e910e..6afde7fcc 100644 --- a/Core/Resgrid.Services/DepartmentDataProtectionService.cs +++ b/Core/Resgrid.Services/DepartmentDataProtectionService.cs @@ -30,11 +30,12 @@ public class DepartmentDataProtectionService : IDepartmentDataProtectionService private readonly IFeatureToggleService _featureToggleService; private readonly ISubscriptionsService _subscriptionsService; private readonly ICacheProvider _cacheProvider; + private readonly IProtectedFieldCatalog _fieldCatalog; public DepartmentDataProtectionService(IDepartmentDataProtectionPolicyRepository policyRepository, IDepartmentProtectedDataEgressPolicyRepository egressPolicyRepository, IDepartmentsService departmentsService, IFeatureToggleService featureToggleService, ISubscriptionsService subscriptionsService, - ICacheProvider cacheProvider) + ICacheProvider cacheProvider, IProtectedFieldCatalog fieldCatalog) { _policyRepository = policyRepository; _egressPolicyRepository = egressPolicyRepository; @@ -42,6 +43,25 @@ public DepartmentDataProtectionService(IDepartmentDataProtectionPolicyRepository _featureToggleService = featureToggleService; _subscriptionsService = subscriptionsService; _cacheProvider = cacheProvider; + _fieldCatalog = fieldCatalog; + } + + public async Task GetPinnedCatalogVersionAsync(int departmentId) + { + var policy = await GetPolicyByDepartmentIdAsync(departmentId); + return policy?.CatalogVersion ?? 0; + } + + public async Task IsCatalogUpgradePendingAsync(int departmentId) + { + if (!await ShouldEncryptNewWritesAsync(departmentId)) + return false; + + var pinned = await GetPinnedCatalogVersionAsync(departmentId); + + // A pinned version of zero on an encrypting department means the policy row lost its + // catalog stamp — treat it as owed an upgrade rather than silently current. + return pinned < _fieldCatalog.Version; } public async Task GetPolicyByDepartmentIdAsync(int departmentId, bool bypassCache = false) @@ -105,9 +125,23 @@ public async Task ShouldEncryptNewWritesAsync(int departmentId) public async Task IsProtectionEnforcedAsync(int departmentId) { var state = await GetStateAsync(departmentId); - return state == DepartmentDataProtectionState.Enabled + if (state == DepartmentDataProtectionState.Enabled || state == DepartmentDataProtectionState.Rotating - || state == DepartmentDataProtectionState.OffboardingScheduled; + || state == DepartmentDataProtectionState.OffboardingScheduled) + return true; + + // A CATALOG UPGRADE runs through the Encrypting state on a department that is ALREADY + // protected: its corpus is fully enveloped and only the newly cataloged fields are being + // swept. Enforcement must stay on for the duration — dropping it would hand rgdp + // ciphertext straight to clients through the unenforced read path. (Enrollment's + // Encrypting is different: nothing is encrypted yet, so there is nothing to enforce.) + if (state == DepartmentDataProtectionState.Encrypting || state == DepartmentDataProtectionState.Verifying) + { + var policy = await GetPolicyByDepartmentIdAsync(departmentId); + return policy?.ActiveMigrationKind == (int)DepartmentDataProtectionMigrationKind.CatalogUpgrade; + } + + return false; } public async Task QueueEnrollmentAsync(int departmentId, string requestingUserId, diff --git a/Core/Resgrid.Services/DepartmentMemberEmergencyContactService.cs b/Core/Resgrid.Services/DepartmentMemberEmergencyContactService.cs new file mode 100644 index 000000000..4a2d96929 --- /dev/null +++ b/Core/Resgrid.Services/DepartmentMemberEmergencyContactService.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// See . + public class DepartmentMemberEmergencyContactService : IDepartmentMemberEmergencyContactService + { + private readonly IDepartmentMemberEmergencyContactRepository _repository; + + // Lazy: defers the protected-write graph (broker client) until a save actually needs it. + private readonly Lazy _protectedWriteService; + + public DepartmentMemberEmergencyContactService(IDepartmentMemberEmergencyContactRepository repository, + Lazy protectedWriteService) + { + _repository = repository; + _protectedWriteService = protectedWriteService; + } + + public async Task> GetAllForMemberAsync(int departmentId, string userId) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId)) + return new List(); + + var contacts = await _repository.GetAllByDepartmentAndUserAsync(departmentId, userId); + + return contacts?.ToList() ?? new List(); + } + + public async Task SaveAsync(DepartmentMemberEmergencyContact contact, + CancellationToken cancellationToken = default) + { + if (contact == null) + return null; + + if (contact.DepartmentMemberEmergencyContactId == 0) + contact.CreatedOn = DateTime.UtcNow; + else + contact.UpdatedOn = DateTime.UtcNow; + + var saved = await _repository.SaveOrUpdateAsync(contact, cancellationToken); + + // ADP write safety net (plan 4.2/19.2). Runs AFTER the save so the identity pk exists — + // it is the AAD row key — then re-persists the enveloped row. Fails closed by throwing + // rather than leaving next-of-kin details in plaintext. + var protectedWrite = await _protectedWriteService.Value.PrepareMemberEmergencyContactWriteAsync( + saved.DepartmentId, saved, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); emergency contact {saved.DepartmentMemberEmergencyContactId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _repository.SaveOrUpdateAsync(saved, cancellationToken); + + // Exactly one primary per member. "Who do we call first" has to have a single answer, and + // nothing in the schema enforces it. Demotion runs after the save so the new row has its + // id and can exclude itself. The other rows are re-saved through the repository rather + // than this method: only a bool changes, and their cataloged values are already + // enveloped, so putting them through the write net again would be pointless work. + if (saved.IsPrimary) + { + var siblings = await _repository.GetAllByDepartmentAndUserAsync(saved.DepartmentId, saved.UserId); + + foreach (var other in (siblings ?? Enumerable.Empty()) + .Where(x => x != null && x.IsPrimary && + x.DepartmentMemberEmergencyContactId != saved.DepartmentMemberEmergencyContactId)) + { + other.IsPrimary = false; + other.UpdatedOn = DateTime.UtcNow; + other.UpdatedByUserId = saved.UpdatedByUserId; + await _repository.SaveOrUpdateAsync(other, cancellationToken); + } + } + + return saved; + } + + public async Task DeleteAllForMemberAsync(int departmentId, string userId, + CancellationToken cancellationToken = default) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId)) + return 0; + + // Hard delete, unlike the per-contact soft delete above: an account deletion must not + // leave a third party's name and phone number sitting in the table under an IsDeleted + // flag. Envelopes go with the rows, so there is nothing to decrypt first. + return await _repository.DeleteAllByDepartmentAndUserAsync(departmentId, userId); + } + + public async Task DeleteAsync(int departmentMemberEmergencyContactId, int departmentId, string userId, + string deletingUserId, CancellationToken cancellationToken = default) + { + // Scoped by department AND user: an id alone must never be enough to remove another + // member's contact. + var contacts = await _repository.GetAllByDepartmentAndUserAsync(departmentId, userId); + var contact = contacts?.FirstOrDefault(x => x.DepartmentMemberEmergencyContactId == departmentMemberEmergencyContactId); + + if (contact == null) + return false; + + contact.IsDeleted = true; + contact.UpdatedOn = DateTime.UtcNow; + contact.UpdatedByUserId = deletingUserId; + + // Soft delete only — the row keeps its envelopes, so no decrypt is needed and the + // migration engine's residue counts stay consistent. + await _repository.SaveOrUpdateAsync(contact, cancellationToken); + + return true; + } + } +} diff --git a/Core/Resgrid.Services/DepartmentMemberSensitiveDataService.cs b/Core/Resgrid.Services/DepartmentMemberSensitiveDataService.cs new file mode 100644 index 000000000..55044c4aa --- /dev/null +++ b/Core/Resgrid.Services/DepartmentMemberSensitiveDataService.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// See . + public class DepartmentMemberSensitiveDataService : IDepartmentMemberSensitiveDataService + { + private readonly IDepartmentMemberSensitiveDataRepository _repository; + + // Lazy: defers the protected graph (broker client) until a save or resolve actually needs it. + private readonly Lazy _protectedWriteService; + private readonly Lazy _protectedReadService; + + public DepartmentMemberSensitiveDataService(IDepartmentMemberSensitiveDataRepository repository, + Lazy protectedWriteService, Lazy protectedReadService) + { + _repository = repository; + _protectedWriteService = protectedWriteService; + _protectedReadService = protectedReadService; + } + + public async Task> GetResolvedForDepartmentAsync( + int departmentId, string grantToken, string actingUserId) + { + var rows = (await _repository.GetAllByDepartmentIdAsync(departmentId))?.ToList() + ?? new List(); + + await _protectedReadService.Value.ResolveMemberSensitiveDataForReadAsync(departmentId, rows, + grantToken, actingUserId); + + return rows + .Where(r => !string.IsNullOrWhiteSpace(r.UserId)) + .GroupBy(r => r.UserId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + } + + public async Task ApplyIdentificationNumbersAsync(int departmentId, IEnumerable profiles, + string grantToken, string actingUserId) + { + var list = profiles?.Where(p => p != null).ToList(); + if (list == null || list.Count == 0) + return; + + var byUser = await GetResolvedForDepartmentAsync(departmentId, grantToken, actingUserId); + + foreach (var profile in list) + { + // A member with no row for THIS department has no number here, even if the legacy + // global profile column still holds one for a different department. + profile.IdentificationNumber = profile.UserId != null && byUser.TryGetValue(profile.UserId, out var row) + ? row.IdentificationNumber + : null; + } + } + + public async Task GetByDepartmentAndUserAsync(int departmentId, string userId) + { + if (departmentId <= 0 || string.IsNullOrWhiteSpace(userId)) + return null; + + return await _repository.GetByDepartmentAndUserAsync(departmentId, userId); + } + + public async Task DeleteForMemberAsync(int departmentId, string userId, + CancellationToken cancellationToken = default) + { + var row = await GetByDepartmentAndUserAsync(departmentId, userId); + if (row == null) + return false; + + return await _repository.DeleteAsync(row, cancellationToken); + } + + public async Task SaveAsync(DepartmentMemberSensitiveData data, + CancellationToken cancellationToken = default) + { + if (data == null) + return null; + + if (data.CreatedOn == default) + data.CreatedOn = DateTime.UtcNow; + else + data.UpdatedOn = DateTime.UtcNow; + + // ProtectionId is NOT NULL and has no database default, so a first save has to assign it + // or the insert fails outright. + if (string.IsNullOrWhiteSpace(data.ProtectionId)) + data.ProtectionId = Guid.NewGuid().ToString("N"); + + var saved = await _repository.SaveOrUpdateAsync(data, cancellationToken); + + // ADP write safety net (plan 4.2/19.2). Runs AFTER the save so the identity pk exists — + // it is the AAD row key — then re-persists the enveloped row. Fails closed by throwing + // rather than leaving a member's identification number in plaintext. + var protectedWrite = await _protectedWriteService.Value.PrepareMemberSensitiveDataWriteAsync( + saved.DepartmentId, saved, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); member sensitive data {saved.DepartmentMemberSensitiveDataId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _repository.SaveOrUpdateAsync(saved, cancellationToken); + + return saved; + } + } +} diff --git a/Core/Resgrid.Services/GdprDataExportService.cs b/Core/Resgrid.Services/GdprDataExportService.cs index a866ced7c..b4968ba89 100644 --- a/Core/Resgrid.Services/GdprDataExportService.cs +++ b/Core/Resgrid.Services/GdprDataExportService.cs @@ -8,6 +8,8 @@ using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Resgrid.Localization.Areas.User.SystemMessages; using Resgrid.Config; using Resgrid.Framework; using Resgrid.Model; @@ -20,6 +22,8 @@ public class GdprDataExportService : IGdprDataExportService { private readonly IGdprDataExportRequestRepository _repository; private readonly IUserProfileService _userProfileService; + private readonly IDepartmentMemberSensitiveDataService _memberSensitiveDataService; + private readonly IDepartmentMemberEmergencyContactService _emergencyContactService; private readonly IUsersService _usersService; private readonly IDepartmentsService _departmentsService; private readonly IDepartmentGroupsService _departmentGroupsService; @@ -34,6 +38,8 @@ public class GdprDataExportService : IGdprDataExportService public GdprDataExportService( IGdprDataExportRequestRepository repository, IUserProfileService userProfileService, + IDepartmentMemberSensitiveDataService memberSensitiveDataService, + IDepartmentMemberEmergencyContactService emergencyContactService, IUsersService usersService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, @@ -47,6 +53,8 @@ public GdprDataExportService( { _repository = repository; _userProfileService = userProfileService; + _memberSensitiveDataService = memberSensitiveDataService; + _emergencyContactService = emergencyContactService; _usersService = usersService; _departmentsService = departmentsService; _departmentGroupsService = departmentGroupsService; @@ -158,31 +166,219 @@ public async Task MarkDownloadedAsync(GdprDataExportRequest request, Cancellatio private async Task BuildExportZipAsync(string userId, int departmentId) { + var ledger = new RedactionLedger(); + using var ms = new MemoryStream(); using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { - await AddJsonEntry(archive, "profile.json", await BuildProfileDataAsync(userId)); - await AddJsonEntry(archive, "membership.json", await BuildMembershipDataAsync(userId, departmentId)); - await AddJsonEntry(archive, "action_logs.json", await BuildActionLogsDataAsync(userId)); - await AddJsonEntry(archive, "messages_inbox.json", await BuildInboxMessagesDataAsync(userId)); - await AddJsonEntry(archive, "messages_sent.json", await BuildSentMessagesDataAsync(userId)); - await AddJsonEntry(archive, "certifications.json", await BuildCertificationsDataAsync(userId)); - await AddJsonEntry(archive, "trainings.json", await BuildTrainingsDataAsync(userId)); - await AddJsonEntry(archive, "shifts.json", await BuildShiftsDataAsync(userId)); + await AddJsonEntry(archive, "profile.json", await BuildProfileDataAsync(userId), ledger); + await AddJsonEntry(archive, "membership.json", await BuildMembershipDataAsync(userId, departmentId), ledger); + await AddJsonEntry(archive, "action_logs.json", await BuildActionLogsDataAsync(userId), ledger); + await AddJsonEntry(archive, "messages_inbox.json", await BuildInboxMessagesDataAsync(userId), ledger); + await AddJsonEntry(archive, "messages_sent.json", await BuildSentMessagesDataAsync(userId), ledger); + await AddJsonEntry(archive, "certifications.json", await BuildCertificationsDataAsync(userId), ledger); + await AddJsonEntry(archive, "trainings.json", await BuildTrainingsDataAsync(userId), ledger); + await AddJsonEntry(archive, "shifts.json", await BuildShiftsDataAsync(userId), ledger); + + // Written last, so it can report what every other entry withheld. Only present when + // something actually was: a member of an unprotected department gets the archive they + // always got, with no extra file to explain. + if (ledger.Total > 0) + { + var profile = await _userProfileService.GetProfileByUserIdAsync(userId); + await AddManifestEntry(archive, ledger, profile?.Language); + } } return ms.ToArray(); } - private static async Task AddJsonEntry(ZipArchive archive, string fileName, object data) + /// + /// Counts of protected values held back from the archive, by entry and field path. + /// + private sealed class RedactionLedger + { + public readonly Dictionary> Fields = + new Dictionary>(StringComparer.Ordinal); + + public readonly Dictionary Counts = new Dictionary(StringComparer.Ordinal); + + public int Total { get; private set; } + + public void Record(string fileName, string path) + { + if (!Fields.TryGetValue(fileName, out var paths)) + { + paths = new SortedSet(StringComparer.Ordinal); + Fields[fileName] = paths; + Counts[fileName] = 0; + } + + paths.Add(path); + Counts[fileName] = Counts[fileName] + 1; + Total++; + } + } + + private static async Task AddJsonEntry(ZipArchive archive, string fileName, object data, RedactionLedger ledger) { var entry = archive.CreateEntry(fileName, CompressionLevel.Optimal); using var entryStream = entry.Open(); + var json = JsonConvert.SerializeObject(data, Formatting.Indented); + json = Sanitize(json, fileName, ledger); + var bytes = Encoding.UTF8.GetBytes(json); await entryStream.WriteAsync(bytes, 0, bytes.Length); } + /// + /// Last line of defence before anything is written to the archive: walks the serialized JSON + /// and replaces every ADP envelope with the REDACTED placeholder. + /// + /// Deliberately shape-driven rather than a per-entity field list. This export runs unattended + /// with no protected-data grant (plan section 3.4 — background jobs cannot obtain user + /// grants), so a cataloged value reaches it as ciphertext, and the archive is stored in the + /// database for up to seven days. A field list would have to be revisited every time the + /// catalog grows or an entry is added here, and the failure mode of forgetting is silent + /// ciphertext in a member's download. Detecting the envelope itself cannot be forgotten. + /// + private static string Sanitize(string json, string fileName, RedactionLedger ledger) + { + if (string.IsNullOrWhiteSpace(json)) + return json; + + JToken root; + try + { + root = JToken.Parse(json); + } + catch (JsonException ex) + { + // Unparseable output should never happen — it was just serialized. Fail closed rather + // than shipping bytes nothing has inspected. + Logging.LogException(ex, $"GDPR export: could not inspect {fileName} for protected values"); + throw; + } + + // Descendants() lives on JContainer; an entry that serialized to a bare scalar still has + // to be inspected, so handle that case rather than skipping it. + var values = root is JContainer container + ? container.Descendants().OfType().ToList() + : root is JValue rootValue + ? new List { rootValue } + : new List(); + + foreach (var value in values) + { + if (value.Type != JTokenType.String) + continue; + + var text = value.Value(); + var path = NormalizePath(value.Path); + + // Already redacted upstream (membership data is resolved through the read pipeline). + // Not rewritten, but still reported — otherwise the manifest would claim nothing was + // withheld from an entry the member can see gaps in. + if (string.Equals(text, ProtectedDataEnvelope.RedactionValue, StringComparison.Ordinal)) + { + ledger.Record(fileName, path); + continue; + } + + if (!IsProtectedPayload(text)) + continue; + + value.Value = ProtectedDataEnvelope.RedactionValue; + ledger.Record(fileName, path); + } + + return root.ToString(Formatting.Indented); + } + + /// + /// True for a text envelope, and for a binary envelope that has been serialized as base64. + /// A byte[] carrying rgdpb: reaches JSON as base64, so its prefix is checked in that + /// encoding rather than decoding what may be a very large payload. + /// + private static bool IsProtectedPayload(string value) + { + if (ProtectedDataEnvelope.HasEnvelopePrefix(value)) + return true; + + return value != null && value.Length >= BinaryPrefixBase64.Length && + value.StartsWith(BinaryPrefixBase64, StringComparison.Ordinal); + } + + /// + /// Base64 of the binary envelope prefix. The prefix is six bytes, which encodes to exactly + /// eight base64 characters with no padding, so any base64 payload that starts with those + /// bytes starts with this string. + /// + private static readonly string BinaryPrefixBase64 = + Convert.ToBase64String(Encoding.ASCII.GetBytes(ProtectedDataEnvelope.BinaryPrefix)); + + /// + /// Collapses array indices so the manifest names a field once rather than once per row — + /// "certifications[0].name" and "certifications[41].name" are the same withheld field. + /// + private static string NormalizePath(string path) + { + if (string.IsNullOrEmpty(path)) + return path; + + var builder = new StringBuilder(path.Length); + var inIndex = false; + + foreach (var c in path) + { + if (c == '[') + { + inIndex = true; + builder.Append("[]"); + continue; + } + + if (c == ']') + { + inIndex = false; + continue; + } + + if (!inIndex) + builder.Append(c); + } + + return builder.ToString(); + } + + /// + /// Says plainly what the archive does not contain and how to get it. An export that silently + /// drops a member's own data is worse than one that admits to it — this is a subject access + /// request, and the gap is the part they will ask about. + /// + private static async Task AddManifestEntry(ZipArchive archive, RedactionLedger ledger, string culture) + { + var entries = ledger.Fields.ToDictionary( + kvp => kvp.Key, + kvp => (object)new { valuesWithheld = ledger.Counts[kvp.Key], fields = kvp.Value.ToArray() }, + StringComparer.Ordinal); + + var manifest = new + { + notice = SystemMessagesResources.Get("GdprExportWithheldNotice", culture), + howToObtain = SystemMessagesResources.Get("GdprExportWithheldHowTo", culture), + placeholder = ProtectedDataEnvelope.RedactionValue, + totalValuesWithheld = ledger.Total, + entries + }; + + var entry = archive.CreateEntry("withheld.json", CompressionLevel.Optimal); + using var entryStream = entry.Open(); + var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(manifest, Formatting.Indented)); + await entryStream.WriteAsync(bytes, 0, bytes.Length); + } + private async Task BuildProfileDataAsync(string userId) { var profile = await _userProfileService.GetProfileByUserIdAsync(userId); @@ -195,7 +391,18 @@ private async Task BuildMembershipDataAsync(string userId, int departmen var member = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId); var group = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId); var roles = await _personnelRolesService.GetRolesForUserAsync(userId, departmentId); - return new { member, group, roles }; + + // The identification number, addresses and emergency contacts are department-scoped + // (ADP plan 5.1) and no longer reachable through profile.json, so a subject access + // request would come back short without them. Resolved for read: this export runs + // unattended with no grant, so a protected department yields the REDACTED placeholder + // rather than raw ciphertext. + var sensitiveByUser = await _memberSensitiveDataService.GetResolvedForDepartmentAsync( + departmentId, null, userId); + sensitiveByUser.TryGetValue(userId, out var sensitive); + var emergencyContacts = await _emergencyContactService.GetAllForMemberAsync(departmentId, userId); + + return new { member, group, roles, sensitive, emergencyContacts }; } private async Task BuildActionLogsDataAsync(string userId) diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index a58cc80a2..0d34938c7 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -855,7 +855,7 @@ static bool IsUnitKind(int kind) => kind == (int)ResourceAssignmentKind.RealUnit DepartmentId = command.DepartmentId, CallId = command.CallId, Name = command.Name, - CallName = call?.Name, + CallName = ProtectedDataEnvelope.SafeDisplay(call?.Name), CallNumber = call?.Number, CallAddress = call?.Address, Status = command.Status, diff --git a/Core/Resgrid.Services/MemberProfileRelocationService.cs b/Core/Resgrid.Services/MemberProfileRelocationService.cs new file mode 100644 index 000000000..5f95e528d --- /dev/null +++ b/Core/Resgrid.Services/MemberProfileRelocationService.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// See . + public class MemberProfileRelocationService : IMemberProfileRelocationService + { + private readonly IDepartmentMemberSensitiveDataRepository _repository; + private readonly IDepartmentMemberSensitiveDataService _sensitiveDataService; + private readonly IUserProfileService _userProfileService; + private readonly IAddressService _addressService; + + public MemberProfileRelocationService(IDepartmentMemberSensitiveDataRepository repository, + IDepartmentMemberSensitiveDataService sensitiveDataService, IUserProfileService userProfileService, + IAddressService addressService) + { + _repository = repository; + _sensitiveDataService = sensitiveDataService; + _userProfileService = userProfileService; + _addressService = addressService; + } + + public async Task> GetDepartmentIdsWithOutstandingDataAsync() + { + var ids = await _repository.GetDepartmentIdsWithOutstandingLegacyProfileDataAsync(); + return ids?.Distinct().OrderBy(x => x).ToList() ?? new List(); + } + + public async Task RelocateDepartmentAsync(int departmentId, + CancellationToken cancellationToken = default) + { + var result = new MemberProfileRelocationResult { DepartmentId = departmentId }; + if (departmentId <= 0) + return result; + + // Includes disabled and deleted members on purpose: their identification number and + // address are still their personal data and still have to end up under the department's + // protection, and leaving them behind would keep the backlog non-empty forever. + var profiles = await _userProfileService.GetAllProfilesForDepartmentIncDisabledDeletedAsync(departmentId); + if (profiles == null || profiles.Count == 0) + return result; + + var rows = (await _repository.GetAllByDepartmentIdAsync(departmentId))?.ToList() + ?? new List(); + + // Read straight from the repository, NOT through the read pipeline: this pass must see + // whether a target column already holds something, and an rgdp envelope counts as + // "already has a value". Resolving first would hand back REDACTED sentinels for a + // protected department and make populated fields look empty. + var byUser = rows + .Where(r => !string.IsNullOrWhiteSpace(r.UserId)) + .GroupBy(r => r.UserId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + + var addressCache = new Dictionary(); + + async Task
GetAddressAsync(int addressId) + { + if (addressCache.TryGetValue(addressId, out var cached)) + return cached; + + Address address = null; + try + { + address = await _addressService.GetAddressByIdAsync(addressId); + } + catch (Exception ex) + { + Logging.LogException(ex, $"MemberProfileRelocation: address {addressId} for department {departmentId}"); + } + + addressCache[addressId] = address; + return address; + } + + foreach (var profile in profiles.Values.Where(p => p != null && !string.IsNullOrWhiteSpace(p.UserId))) + { + cancellationToken.ThrowIfCancellationRequested(); + + byUser.TryGetValue(profile.UserId, out var row); + if (row != null && row.LegacyProfileRelocatedOn.HasValue) + continue; + + result.MembersExamined++; + + try + { + var created = row == null; + row ??= new DepartmentMemberSensitiveData { DepartmentId = departmentId, UserId = profile.UserId }; + + if (string.IsNullOrWhiteSpace(row.IdentificationNumber) && + !string.IsNullOrWhiteSpace(profile.IdentificationNumber)) + { + row.IdentificationNumber = profile.IdentificationNumber; + result.IdentificationNumbersMoved++; + } + + if (string.IsNullOrWhiteSpace(row.HomeAddress1) && profile.HomeAddressId.HasValue) + { + var home = await GetAddressAsync(profile.HomeAddressId.Value); + if (home != null && !string.IsNullOrWhiteSpace(home.Address1)) + { + row.HomeAddress1 = home.Address1; + row.HomeCity = home.City; + row.HomeState = home.State; + row.HomePostalCode = home.PostalCode; + row.HomeCountry = home.Country; + result.AddressesMoved++; + } + } + + if (string.IsNullOrWhiteSpace(row.MailingAddress1) && profile.MailingAddressId.HasValue) + { + var mailing = await GetAddressAsync(profile.MailingAddressId.Value); + if (mailing != null && !string.IsNullOrWhiteSpace(mailing.Address1)) + { + row.MailingAddress1 = mailing.Address1; + row.MailingCity = mailing.City; + row.MailingState = mailing.State; + row.MailingPostalCode = mailing.PostalCode; + row.MailingCountry = mailing.Country; + result.AddressesMoved++; + } + } + + // Marked even when nothing moved. The marker means "this member has been through + // relocation", not "this member had data"; without that, every member who never + // filled in an address would be re-examined on every pass forever. + row.LegacyProfileRelocatedOn = DateTime.UtcNow; + + // Through the service, so the ADP write safety net runs: for an enrolled + // department the values are enveloped as they land, and a blocked write throws + // rather than parking plaintext in a protected row. + await _sensitiveDataService.SaveAsync(row, cancellationToken); + + if (created) + result.RowsCreated++; + } + catch (Exception ex) + { + // The marker was never persisted, so this member is picked up again next pass. + result.Failures++; + Logging.LogException(ex, $"MemberProfileRelocation: department {departmentId}, user {profile.UserId}"); + } + } + + return result; + } + } +} diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index 7bb422a53..3ebf67da5 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -7,7 +7,7 @@ namespace Resgrid.Services { /// - /// Catalog v1 (draft until the Phase 0 catalog freeze): the P0 families from ADP plan section 5.1 + /// Catalog v2 (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 @@ -19,6 +19,22 @@ public class ProtectedFieldCatalog : IProtectedFieldCatalog private const string CallsFamily = "Calls"; private const string PersonnelFamily = "Personnel"; private const string ContactsFamily = "Contacts"; + private const string OperationalFamily = "Operational"; + + /// Catalog version the section 5.2 operational entries were added in. + private const int OperationalCatalogVersion = 2; + + /// Catalog version the section 5.2 Log (incident report) family was added in. + private const int LogCatalogVersion = 3; + + /// Catalog version the department-scoped emergency-contact family was added in. + private const int EmergencyContactCatalogVersion = 4; + + /// Catalog version the department-scoped member address columns were added in. + private const int MemberAddressCatalogVersion = 5; + + /// Catalog version the personnel certification family was added in. + private const int CertificationCatalogVersion = 6; private static readonly IReadOnlyList Entries = BuildV1(); private static readonly Dictionary ById = @@ -27,10 +43,23 @@ public class ProtectedFieldCatalog : IProtectedFieldCatalog Entries.GroupBy(e => e.TableName, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList(), StringComparer.OrdinalIgnoreCase); - public int Version => 1; + /// + /// Current catalog version. MUST equal the highest AddedInCatalogVersion in the entries — + /// version-scoped queries and the upgrade work list are meaningless if the constant lags the + /// data, so it is derived rather than hand-maintained. + /// + public int Version { get; } = Entries.Max(e => e.AddedInCatalogVersion); public IReadOnlyList GetAll() => Entries; + public IReadOnlyList GetAllForVersion(int catalogVersion) + { + if (catalogVersion <= 0) + return Array.Empty(); + + return Entries.Where(e => e.AddedInCatalogVersion <= catalogVersion).ToList(); + } + public IReadOnlyList GetForTable(string tableName) { if (string.IsNullOrWhiteSpace(tableName)) @@ -39,6 +68,24 @@ public IReadOnlyList GetForTable(string tableName) return ByTable.TryGetValue(tableName, out var entries) ? entries : Array.Empty(); } + public IReadOnlyList GetForTableAndVersion(string tableName, int catalogVersion) + { + if (catalogVersion <= 0) + return Array.Empty(); + + return GetForTable(tableName).Where(e => e.AddedInCatalogVersion <= catalogVersion).ToList(); + } + + public IReadOnlyList GetAddedBetween(int fromCatalogVersion, int toCatalogVersion) + { + if (toCatalogVersion <= fromCatalogVersion) + return Array.Empty(); + + return Entries + .Where(e => e.AddedInCatalogVersion > fromCatalogVersion && e.AddedInCatalogVersion <= toCatalogVersion) + .ToList(); + } + public ProtectedFieldDefinition GetById(string fieldId) { if (string.IsNullOrWhiteSpace(fieldId)) @@ -109,8 +156,6 @@ void Member(string column, ProtectedFieldClassification 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, @@ -143,6 +188,100 @@ void Contact(string column, ProtectedFieldClassification classification, Contact("ExitGpsCoordinates", ProtectedFieldClassification.Pii); Contact("LocationGeofence", ProtectedFieldClassification.Pii); + // ---- Operational free-form data (section 5.2), catalog v2 ------------------------- + // UdfFieldValues.Value is user-authored free text on any entity; the plan defaults free + // text to sensitive in a protected department. UnitStates carry the crew's own note and + // the position it was filed from — protected location data rides the companion columns. + void Operational(string table, string column, ProtectedFieldClassification classification, + ProtectedFieldStorageKind kind = ProtectedFieldStorageKind.Text) => + list.Add(new ProtectedFieldDefinition($"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", + OperationalFamily, table, column, kind, classification, + PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, + OperationalCatalogVersion)); + + // ---- Personnel certifications (section 5.1: "license/certification numbers and + // documents"), catalog v6. The document itself is the binary field — a certificate scan + // carries the member's name, licence number and often their signature, so protecting the + // metadata while serving the file in the clear would protect nothing. + void Certification(string column, ProtectedFieldClassification classification, + ProtectedFieldStorageKind kind = ProtectedFieldStorageKind.Text) => + list.Add(new ProtectedFieldDefinition($"personnelcertifications.{column.ToLowerInvariant()}", + PersonnelFamily, "PersonnelCertifications", column, kind, classification, + PermissionTypes.ViewProtectedPersonnelData, PermissionTypes.ViewProtectedPersonnelData, + CertificationCatalogVersion)); + + Certification("Name", ProtectedFieldClassification.Pii); + Certification("Number", ProtectedFieldClassification.Pii); + Certification("Type", ProtectedFieldClassification.Sensitive); + Certification("Area", ProtectedFieldClassification.Sensitive); + Certification("IssuedBy", ProtectedFieldClassification.Sensitive); + Certification("Filename", ProtectedFieldClassification.Sensitive); + Certification("Data", ProtectedFieldClassification.Pii, ProtectedFieldStorageKind.Binary); + + // ---- Member addresses (section 5.1), catalog v5 ----------------------------------- + // An address is protected as a UNIT: leaving the city, state or postal code in the clear + // while encrypting the street line still re-identifies the member in a small department. + void MemberAddress(string column) => + list.Add(new ProtectedFieldDefinition($"departmentmembersensitivedata.{column.ToLowerInvariant()}", + PersonnelFamily, "DepartmentMemberSensitiveData", column, ProtectedFieldStorageKind.Text, + ProtectedFieldClassification.Pii, PermissionTypes.ViewProtectedPersonnelData, + PermissionTypes.ViewProtectedPersonnelData, MemberAddressCatalogVersion)); + + MemberAddress("HomeAddress1"); + MemberAddress("HomeCity"); + MemberAddress("HomeState"); + MemberAddress("HomePostalCode"); + MemberAddress("HomeCountry"); + MemberAddress("MailingAddress1"); + MemberAddress("MailingCity"); + MemberAddress("MailingState"); + MemberAddress("MailingPostalCode"); + MemberAddress("MailingCountry"); + + // ---- Member emergency contacts (section 5.1), catalog v4 --------------------------- + // A member may have several per department, and the values may differ per department. + // These numbers ARE encrypted: they are next-of-kin reference data an authorized human + // reads, never an outbound channel handed to an SMS or voice provider (the rule that + // member NOTIFICATION numbers stay plaintext does not reach them). + void EmergencyContact(string column, ProtectedFieldClassification classification) => + list.Add(new ProtectedFieldDefinition($"departmentmemberemergencycontacts.{column.ToLowerInvariant()}", + PersonnelFamily, "DepartmentMemberEmergencyContacts", column, ProtectedFieldStorageKind.Text, + classification, PermissionTypes.ViewProtectedPersonnelData, PermissionTypes.ViewProtectedPersonnelData, + EmergencyContactCatalogVersion)); + + EmergencyContact("Name", ProtectedFieldClassification.Pii); + EmergencyContact("Relationship", ProtectedFieldClassification.Pii); + EmergencyContact("PhoneNumber", ProtectedFieldClassification.Pii); + EmergencyContact("AlternatePhoneNumber", ProtectedFieldClassification.Pii); + EmergencyContact("Email", ProtectedFieldClassification.Pii); + EmergencyContact("Notes", ProtectedFieldClassification.Sensitive); + + // ---- Logs (section 5.2), catalog v3 ----------------------------------------------- + // The incident/NFIRS-style log. NOTE: the separate CallLogs.Narrative entry above covers a + // DIFFERENT table (call activity logs); both carry a Narrative column and both are + // user-authored, so both are cataloged independently. + void LogField(string column, ProtectedFieldClassification classification) => + list.Add(new ProtectedFieldDefinition($"logs.{column.ToLowerInvariant()}", OperationalFamily, "Logs", + column, ProtectedFieldStorageKind.Text, classification, + PermissionTypes.ViewProtectedOperationalData, PermissionTypes.EditProtectedCallData, + LogCatalogVersion)); + + LogField("Narrative", ProtectedFieldClassification.Phi); + LogField("InitialReport", ProtectedFieldClassification.Phi); + LogField("Cause", ProtectedFieldClassification.Sensitive); + LogField("ContactName", ProtectedFieldClassification.Sensitive); + LogField("ContactNumber", ProtectedFieldClassification.Sensitive); + LogField("OtherPersonnel", ProtectedFieldClassification.Sensitive); + LogField("Location", ProtectedFieldClassification.Sensitive); + LogField("BodyLocation", ProtectedFieldClassification.Phi); + LogField("PronouncedDeceasedBy", ProtectedFieldClassification.Phi); + + Operational("UdfFieldValues", "Value", ProtectedFieldClassification.Sensitive); + Operational("UnitStates", "Note", ProtectedFieldClassification.Sensitive); + Operational("UnitStates", "GeoLocationData", ProtectedFieldClassification.Sensitive); + Operational("UnitStates", "Latitude", ProtectedFieldClassification.Sensitive, ProtectedFieldStorageKind.CompanionColumn); + Operational("UnitStates", "Longitude", ProtectedFieldClassification.Sensitive, ProtectedFieldStorageKind.CompanionColumn); + list.Add(new ProtectedFieldDefinition("contactnotes.note", ContactsFamily, "ContactNotes", "Note", ProtectedFieldStorageKind.Text, ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedContactData)); diff --git a/Core/Resgrid.Services/ProtectedFieldCryptoService.cs b/Core/Resgrid.Services/ProtectedFieldCryptoService.cs index f609dfbad..ad1c96e8f 100644 --- a/Core/Resgrid.Services/ProtectedFieldCryptoService.cs +++ b/Core/Resgrid.Services/ProtectedFieldCryptoService.cs @@ -21,7 +21,7 @@ public class ProtectedFieldCryptoService : IProtectedFieldCryptoService private const int TagSize = 16; public string EncryptText(byte[] dek, int departmentKeyVersion, string plaintext, - int departmentId, string catalogFieldId, string rowKey, int catalogVersion) + int departmentId, string catalogFieldId, string rowKey) { if (plaintext == null) throw new ArgumentNullException(nameof(plaintext)); @@ -33,7 +33,7 @@ public string EncryptText(byte[] dek, int departmentKeyVersion, string plaintext try { var payload = Seal(dek, plainBytes, - Aad(departmentId, catalogFieldId, rowKey, ProtectedDataEnvelope.CurrentVersion, catalogVersion)); + Aad(departmentId, catalogFieldId, rowKey, ProtectedDataEnvelope.CurrentVersion)); return ProtectedDataEnvelope.Format(departmentKeyVersion, Convert.ToBase64String(payload)); } finally @@ -43,7 +43,7 @@ public string EncryptText(byte[] dek, int departmentKeyVersion, string plaintext } public string DecryptText(byte[] dek, string envelope, - int departmentId, string catalogFieldId, string rowKey, int catalogVersion) + int departmentId, string catalogFieldId, string rowKey) { 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."); @@ -51,7 +51,7 @@ public string DecryptText(byte[] dek, string envelope, 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)); + var plainBytes = Open(dek, payload, Aad(departmentId, catalogFieldId, rowKey, formatVersion)); try { return Encoding.UTF8.GetString(plainBytes); @@ -63,7 +63,7 @@ public string DecryptText(byte[] dek, string envelope, } public byte[] EncryptBinary(byte[] dek, int departmentKeyVersion, byte[] plaintext, - int departmentId, string catalogFieldId, string rowKey, int catalogVersion) + int departmentId, string catalogFieldId, string rowKey) { if (plaintext == null) throw new ArgumentNullException(nameof(plaintext)); @@ -78,7 +78,7 @@ public byte[] EncryptBinary(byte[] dek, int departmentKeyVersion, byte[] plainte var header = Encoding.ASCII.GetBytes($"{ProtectedDataEnvelope.BinaryPrefix}{ProtectedDataEnvelope.CurrentVersion}:{departmentKeyVersion}:"); var payload = Seal(dek, plaintext, - Aad(departmentId, catalogFieldId, rowKey, ProtectedDataEnvelope.CurrentVersion, catalogVersion)); + Aad(departmentId, catalogFieldId, rowKey, ProtectedDataEnvelope.CurrentVersion)); var result = new byte[header.Length + payload.Length]; Buffer.BlockCopy(header, 0, result, 0, header.Length); @@ -87,7 +87,7 @@ public byte[] EncryptBinary(byte[] dek, int departmentKeyVersion, byte[] plainte } public byte[] DecryptBinary(byte[] dek, byte[] envelope, - int departmentId, string catalogFieldId, string rowKey, int catalogVersion) + int departmentId, string catalogFieldId, string rowKey) { if (!TryParseBinaryHeader(envelope, out var payloadOffset, out var formatVersion)) throw new CryptographicException("Blob is not a parseable rgdpb envelope of a supported version."); @@ -95,7 +95,7 @@ public byte[] DecryptBinary(byte[] dek, byte[] envelope, 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)); + return Open(dek, payload, Aad(departmentId, catalogFieldId, rowKey, formatVersion)); } public bool TryGetBinaryEnvelopeKeyVersion(byte[] value, out int departmentKeyVersion) @@ -125,13 +125,15 @@ public bool IsBinaryEnveloped(byte[] value) /// /// 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 '|'. + /// and the envelope format version. The 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 CATALOG version is + /// deliberately absent: field ids are stable forever, so it adds no binding, and including it + /// would break every stored envelope the moment a department's pinned catalog advanced. 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) + private static byte[] Aad(int departmentId, string catalogFieldId, string rowKey, int envelopeFormatVersion) { if (string.IsNullOrWhiteSpace(catalogFieldId)) throw new ArgumentException("Catalog field id is required for AAD binding.", nameof(catalogFieldId)); @@ -139,7 +141,7 @@ private static byte[] Aad(int departmentId, string catalogFieldId, string 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}")); + $"rgdp|{departmentId}|{catalogFieldId}|{rowKey}|{envelopeFormatVersion}")); } private static byte[] Seal(byte[] dek, byte[] plaintext, byte[] aad) diff --git a/Core/Resgrid.Services/ProtectedProjectionService.cs b/Core/Resgrid.Services/ProtectedProjectionService.cs index fcc8b0d41..8643c6ec9 100644 --- a/Core/Resgrid.Services/ProtectedProjectionService.cs +++ b/Core/Resgrid.Services/ProtectedProjectionService.cs @@ -121,11 +121,11 @@ public async Task BuildNotificationSafeCallAsync(int departmentId, Call ca // AllowProtectedContent lets the original call through — but only while its fields are // actually plaintext. Post-migration the entity carries rgdp envelopes and notification // hosts cannot decrypt (no broker/grant), so an enveloped call degrades to the sanitized - // clone: a carrier must never receive ciphertext as message content. - if (await ChannelAllowsProtectedContentAsync(departmentId, channel) && - !ProtectedDataEnvelope.HasEnvelopePrefix(call.Name) && - !ProtectedDataEnvelope.HasEnvelopePrefix(call.NatureOfCall) && - !ProtectedDataEnvelope.HasEnvelopePrefix(call.Address)) + // clone: a carrier must never receive ciphertext as message content. EVERY cataloged + // field is checked, not a sample: a partially-migrated row can carry an envelope in + // Notes or ContactNumber while Name/Address are still plaintext, and templates, provider + // DTOs and TTS prompts read those fields too. + if (await ChannelAllowsProtectedContentAsync(departmentId, channel) && !HasAnyEnvelopedCallField(call)) return call; // Sanitized clone: only the allowlisted system-generated call number, priority/color, @@ -148,6 +148,21 @@ public async Task BuildNotificationSafeCallAsync(int departmentId, Call ca }; } + /// + /// True when any cataloged Calls field carries an envelope prefix. Driven by the parity-pinned + /// accessor map so a catalog addition is covered without touching this guard. + /// + private static bool HasAnyEnvelopedCallField(Call call) + { + foreach (var accessor in ProtectedReadService.CallFieldAccessors) + { + if (ProtectedDataEnvelope.HasEnvelopePrefix(accessor.Value.Get(call))) + return true; + } + + return false; + } + public async Task IsChannelSanitizedAsync(int departmentId, ProtectedDataEgressChannel channel) { bool enforced; diff --git a/Core/Resgrid.Services/ProtectedPushServiceDecorator.cs b/Core/Resgrid.Services/ProtectedPushServiceDecorator.cs new file mode 100644 index 000000000..3dadfec56 --- /dev/null +++ b/Core/Resgrid.Services/ProtectedPushServiceDecorator.cs @@ -0,0 +1,125 @@ +using System; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Messages; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// Outbound-boundary net for push (ADP plan section 7.5, queue side). Every push the platform + /// sends is composed into a or + /// and handed to , so scrubbing here covers dispatch, notifications + /// and chat without touching each caller. + /// + /// Push is the channel where a leak is most visible and least recoverable: the title and + /// subtitle land on a lock screen, in a notification centre, and in the OS's own logs. It is + /// also a channel that must not be blocked, so this scrubs and logs rather than refusing — + /// the notification still wakes the responder, and the log names the path that skipped its + /// protected projection. + /// + /// Registration-side methods pass straight through; they carry no department content. + /// + public class ProtectedPushServiceDecorator : IPushService + { + private readonly IPushService _inner; + + public ProtectedPushServiceDecorator(IPushService inner) + { + _inner = inner; + } + + public Task PushMessage(StandardPushMessage message, string userId, UserProfile profile = null) + => _inner.PushMessage(Sanitize(message, "message"), userId, profile); + + public Task PushCall(StandardPushCall call, string userId, UserProfile profile = null, + DepartmentCallPriority priority = null) + => _inner.PushCall(Sanitize(call), userId, profile, priority); + + public Task PushNotification(StandardPushMessage message, string userId, UserProfile profile = null) + => _inner.PushNotification(Sanitize(message, "notification"), userId, profile); + + public Task PushICNotification(StandardPushMessage message, string userId, UserProfile profile = null) + => _inner.PushICNotification(Sanitize(message, "ic-notification"), userId, profile); + + public Task PushChat(StandardPushMessage message, string userId, UserProfile profile = null) + => _inner.PushChat(Sanitize(message, "chat"), userId, profile); + + public Task PushCallUnit(StandardPushCall call, int unitId, DepartmentCallPriority priority = null) + => _inner.PushCallUnit(Sanitize(call), unitId, priority); + + public Task PushChatMessage(StandardPushMessage message, string userId, string eventCode, + int unreadCount, bool includeIncidentCommandApp, UserProfile profile = null) + => _inner.PushChatMessage(Sanitize(message, "chat-message"), userId, eventCode, unreadCount, + includeIncidentCommandApp, profile); + + public Task PushChatMessageUnit(StandardPushMessage message, int unitId, string eventCode, int unreadCount) + => _inner.PushChatMessageUnit(Sanitize(message, "chat-message-unit"), unitId, eventCode, unreadCount); + + public Task Register(PushUri pushUri) => _inner.Register(pushUri); + + public Task UnRegister(PushUri pushUri) => _inner.UnRegister(pushUri); + + public void UnRegisterNotificationOnly(PushUri pushUri) => _inner.UnRegisterNotificationOnly(pushUri); + + public Task RegisterUnit(PushUri pushUri) => _inner.RegisterUnit(pushUri); + + public Task UnRegisterUnit(PushUri pushUri) => _inner.UnRegisterUnit(pushUri); + + private static StandardPushMessage Sanitize(StandardPushMessage message, string kind) + { + if (message == null) + return null; + + try + { + var scrubbed = 0; + + message.Title = ProtectedOutboundGuard.Scrub(message.Title, out var titleCount); + scrubbed += titleCount; + + message.SubTitle = ProtectedOutboundGuard.Scrub(message.SubTitle, out var subTitleCount); + scrubbed += subTitleCount; + + if (scrubbed > 0) + Logging.LogError($"ADP outbound net scrubbed {scrubbed} enveloped value(s) from a {kind} push for department " + + $"{message.DepartmentId?.ToString() ?? "unknown"}. A notification path is missing its protected projection."); + } + catch (Exception ex) + { + Logging.LogException(ex, "ProtectedPushServiceDecorator failed while sanitizing a push message"); + } + + return message; + } + + private static StandardPushCall Sanitize(StandardPushCall call) + { + if (call == null) + return null; + + try + { + var scrubbed = 0; + + call.Title = ProtectedOutboundGuard.Scrub(call.Title, out var titleCount); + scrubbed += titleCount; + + call.SubTitle = ProtectedOutboundGuard.Scrub(call.SubTitle, out var subTitleCount); + scrubbed += subTitleCount; + + if (scrubbed > 0) + Logging.LogError($"ADP outbound net scrubbed {scrubbed} enveloped value(s) from a dispatch push for department " + + $"{call.DepartmentId?.ToString() ?? "unknown"} (call {call.CallId}). " + + "A dispatch path is missing its protected projection."); + } + catch (Exception ex) + { + Logging.LogException(ex, "ProtectedPushServiceDecorator failed while sanitizing a dispatch push"); + } + + return call; + } + } +} diff --git a/Core/Resgrid.Services/ProtectedReadService.cs b/Core/Resgrid.Services/ProtectedReadService.cs index acae29983..ff4dc9cfc 100644 --- a/Core/Resgrid.Services/ProtectedReadService.cs +++ b/Core/Resgrid.Services/ProtectedReadService.cs @@ -82,6 +82,32 @@ public class ProtectedReadService : IProtectedReadService, IProtectedWriteServic /// The rgdpb binary attachment payload field id. public const string AttachmentDataFieldId = "callattachments.data"; + /// + /// CallLogs text columns (parity-pinned). CallLogs is the per-call running log, a different + /// table and entity from the Log family's incident work logs — both happen to call their + /// text column Narrative. + /// + public static readonly IReadOnlyDictionary Get, Action Set)> CallLogFieldAccessors = + new Dictionary, Action)> + { + ["calllogs.narrative"] = (l => l.Narrative, (l, v) => l.Narrative = v) + }; + + /// PersonnelCertifications text columns (parity-pinned; Data is the binary field). + public static readonly IReadOnlyDictionary Get, Action Set)> CertificationFieldAccessors = + new Dictionary, Action)> + { + ["personnelcertifications.name"] = (c => c.Name, (c, v) => c.Name = v), + ["personnelcertifications.number"] = (c => c.Number, (c, v) => c.Number = v), + ["personnelcertifications.type"] = (c => c.Type, (c, v) => c.Type = v), + ["personnelcertifications.area"] = (c => c.Area, (c, v) => c.Area = v), + ["personnelcertifications.issuedby"] = (c => c.IssuedBy, (c, v) => c.IssuedBy = v), + ["personnelcertifications.filename"] = (c => c.Filename, (c, v) => c.Filename = v) + }; + + /// The rgdpb binary certification document field id. + public const string CertificationDataFieldId = "personnelcertifications.data"; + /// Contacts text columns (parity-pinned; Image is the separate binary field). public static readonly IReadOnlyDictionary Get, Action Set)> ContactFieldAccessors = new Dictionary, Action)> @@ -119,6 +145,74 @@ public class ProtectedReadService : IProtectedReadService, IProtectedWriteServic ["contactnotes.note"] = (n => n.Note, (n, v) => n.Note = v) }; + /// UnitStates text columns (catalog v2 operational family; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> UnitStateFieldAccessors = + new Dictionary, Action)> + { + ["unitstates.note"] = (s => s.Note, (s, v) => s.Note = v), + ["unitstates.geolocationdata"] = (s => s.GeoLocationData, (s, v) => s.GeoLocationData = v) + }; + + /// UnitStates companion columns (parity-pinned). + public static readonly IReadOnlyDictionary GetEnvelope, Action SetTyped)> UnitStateCompanionAccessors = + new Dictionary, Action)> + { + ["unitstates.latitude"] = (s => s.ProtectedLatitudeEnvelope, (s, v) => s.Latitude = v), + ["unitstates.longitude"] = (s => s.ProtectedLongitudeEnvelope, (s, v) => s.Longitude = v) + }; + + /// DepartmentMemberSensitiveData text columns (catalog v1 personnel family; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> MemberSensitiveDataAccessors = + new Dictionary, Action)> + { + ["departmentmembersensitivedata.identificationnumber"] = (d => d.IdentificationNumber, (d, v) => d.IdentificationNumber = v), + ["departmentmembersensitivedata.notes"] = (d => d.Notes, (d, v) => d.Notes = v), + ["departmentmembersensitivedata.homeaddress1"] = (d => d.HomeAddress1, (d, v) => d.HomeAddress1 = v), + ["departmentmembersensitivedata.homecity"] = (d => d.HomeCity, (d, v) => d.HomeCity = v), + ["departmentmembersensitivedata.homestate"] = (d => d.HomeState, (d, v) => d.HomeState = v), + ["departmentmembersensitivedata.homepostalcode"] = (d => d.HomePostalCode, (d, v) => d.HomePostalCode = v), + ["departmentmembersensitivedata.homecountry"] = (d => d.HomeCountry, (d, v) => d.HomeCountry = v), + ["departmentmembersensitivedata.mailingaddress1"] = (d => d.MailingAddress1, (d, v) => d.MailingAddress1 = v), + ["departmentmembersensitivedata.mailingcity"] = (d => d.MailingCity, (d, v) => d.MailingCity = v), + ["departmentmembersensitivedata.mailingstate"] = (d => d.MailingState, (d, v) => d.MailingState = v), + ["departmentmembersensitivedata.mailingpostalcode"] = (d => d.MailingPostalCode, (d, v) => d.MailingPostalCode = v), + ["departmentmembersensitivedata.mailingcountry"] = (d => d.MailingCountry, (d, v) => d.MailingCountry = v) + }; + + /// DepartmentMemberEmergencyContacts text columns (catalog v4; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> MemberEmergencyContactAccessors = + new Dictionary, Action)> + { + ["departmentmemberemergencycontacts.name"] = (c => c.Name, (c, v) => c.Name = v), + ["departmentmemberemergencycontacts.relationship"] = (c => c.Relationship, (c, v) => c.Relationship = v), + ["departmentmemberemergencycontacts.phonenumber"] = (c => c.PhoneNumber, (c, v) => c.PhoneNumber = v), + ["departmentmemberemergencycontacts.alternatephonenumber"] = (c => c.AlternatePhoneNumber, (c, v) => c.AlternatePhoneNumber = v), + ["departmentmemberemergencycontacts.email"] = (c => c.Email, (c, v) => c.Email = v), + ["departmentmemberemergencycontacts.notes"] = (c => c.Notes, (c, v) => c.Notes = v) + }; + + /// Logs text columns (catalog v3; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> LogFieldAccessors = + new Dictionary, Action)> + { + ["logs.narrative"] = (l => l.Narrative, (l, v) => l.Narrative = v), + ["logs.initialreport"] = (l => l.InitialReport, (l, v) => l.InitialReport = v), + ["logs.cause"] = (l => l.Cause, (l, v) => l.Cause = v), + ["logs.contactname"] = (l => l.ContactName, (l, v) => l.ContactName = v), + ["logs.contactnumber"] = (l => l.ContactNumber, (l, v) => l.ContactNumber = v), + ["logs.otherpersonnel"] = (l => l.OtherPersonnel, (l, v) => l.OtherPersonnel = v), + ["logs.location"] = (l => l.Location, (l, v) => l.Location = v), + ["logs.bodylocation"] = (l => l.BodyLocation, (l, v) => l.BodyLocation = v), + ["logs.pronounceddeceasedby"] = (l => l.PronouncedDeceasedBy, (l, v) => l.PronouncedDeceasedBy = v) + }; + + /// UdfFieldValues text column (catalog v2 operational family; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> UdfFieldValueAccessors = + new Dictionary, Action)> + { + ["udffieldvalues.value"] = (v => v.Value, (v, x) => v.Value = x) + }; + private static readonly byte[] BinaryPrefixBytes = Encoding.ASCII.GetBytes(ProtectedDataEnvelope.BinaryPrefix); /// One protected value wired to its reveal/redact actions on the owning entity. @@ -136,13 +230,16 @@ private sealed class Slot private readonly IDepartmentDataProtectionService _dataProtectionService; private readonly IProtectedDataGrantService _grantService; private readonly IProtectedDataBrokerClient _brokerClient; + private readonly IProtectedFieldCatalog _fieldCatalog; public ProtectedReadService(IDepartmentDataProtectionService dataProtectionService, - IProtectedDataGrantService grantService, IProtectedDataBrokerClient brokerClient) + IProtectedDataGrantService grantService, IProtectedDataBrokerClient brokerClient, + IProtectedFieldCatalog fieldCatalog) { _dataProtectionService = dataProtectionService; _grantService = grantService; _brokerClient = brokerClient; + _fieldCatalog = fieldCatalog; } public async Task ResolveForReadAsync(int departmentId, Call call, @@ -207,6 +304,19 @@ public async Task ResolveAttachmentsForReadAsync(int depart return result; } + public async Task ResolveCertificationsForReadAsync(int departmentId, + IReadOnlyList certifications, string grantToken, string userId, + bool includeData = false, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var certification in (certifications ?? Array.Empty()).Where(c => c != null)) + CollectCertificationSlots(result, certification, slots, includeData); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + public async Task ResolveContactsForReadAsync(int departmentId, IReadOnlyList contacts, string grantToken, string userId, CancellationToken cancellationToken = default) { @@ -219,6 +329,249 @@ public async Task ResolveContactsForReadAsync(int departmen return result; } + public async Task ResolveMemberEmergencyContactsForReadAsync(int departmentId, + IReadOnlyList contacts, string grantToken, string userId, + CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var contact in (contacts ?? Array.Empty()).Where(c => c != null)) + { + var rowKey = contact.DepartmentMemberEmergencyContactId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in MemberEmergencyContactAccessors) + { + var value = accessor.Value.Get(contact); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = result, + Reveal = plaintext => set(contact, plaintext), + Redact = () => set(contact, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task PrepareMemberEmergencyContactWriteAsync(int departmentId, + DepartmentMemberEmergencyContact contact, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default) + { + if (contact == null) + return ProtectedWriteResult.Allowed(); + + var slots = new List(); + var rowKey = contact.DepartmentMemberEmergencyContactId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in MemberEmergencyContactAccessors) + { + var value = accessor.Value.Get(contact); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(contact, envelope) }); + } + + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => contact.IsProtected = true, cancellationToken); + } + + public async Task ResolveMemberSensitiveDataForReadAsync(int departmentId, + IReadOnlyList rows, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var row in (rows ?? Array.Empty()).Where(r => r != null)) + { + var rowKey = row.DepartmentMemberSensitiveDataId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in MemberSensitiveDataAccessors) + { + var value = accessor.Value.Get(row); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = result, + Reveal = plaintext => set(row, plaintext), + Redact = () => set(row, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task PrepareMemberSensitiveDataWriteAsync(int departmentId, + DepartmentMemberSensitiveData data, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default) + { + if (data == null) + return ProtectedWriteResult.Allowed(); + + var slots = new List(); + var rowKey = data.DepartmentMemberSensitiveDataId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in MemberSensitiveDataAccessors) + { + var value = accessor.Value.Get(data); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(data, envelope) }); + } + + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => data.IsProtected = true, cancellationToken); + } + + public async Task ResolveCallLogsForReadAsync(int departmentId, + IReadOnlyList logs, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var log in (logs ?? Array.Empty()).Where(l => l != null)) + { + var rowKey = log.CallLogId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in CallLogFieldAccessors) + { + var value = accessor.Value.Get(log); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = result, + Reveal = plaintext => set(log, plaintext), + Redact = () => set(log, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveLogsForReadAsync(int departmentId, + IReadOnlyList logs, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var log in (logs ?? Array.Empty()).Where(l => l != null)) + { + var rowKey = log.LogId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in LogFieldAccessors) + { + var value = accessor.Value.Get(log); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = result, + Reveal = plaintext => set(log, plaintext), + Redact = () => set(log, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task PrepareCallLogWriteAsync(int departmentId, CallLog log, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (log == null) + return ProtectedWriteResult.Allowed(); + + var slots = new List(); + var rowKey = log.CallLogId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in CallLogFieldAccessors) + { + var value = accessor.Value.Get(log); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(log, envelope) }); + } + + // No marker column on CallLogs, so nothing to flag — the sweep and the residue counts + // work off envelope detection on the column itself. + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + } + + public async Task PrepareLogWriteAsync(int departmentId, Log log, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (log == null) + return ProtectedWriteResult.Allowed(); + + var slots = new List(); + var rowKey = log.LogId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in LogFieldAccessors) + { + var value = accessor.Value.Get(log); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(log, envelope) }); + } + + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + } + + public async Task ResolveUnitStatesForReadAsync(int departmentId, + IReadOnlyList states, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var state in (states ?? Array.Empty()).Where(s => s != null)) + CollectUnitStateSlots(result, state, slots); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveUdfFieldValuesForReadAsync(int departmentId, + IReadOnlyList values, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var value in (values ?? Array.Empty()).Where(v => v != null)) + CollectUdfFieldValueSlots(result, value, slots); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + public async Task ResolveContactNotesForReadAsync(int departmentId, IReadOnlyList notes, string grantToken, string userId, CancellationToken cancellationToken = default) { @@ -473,6 +826,54 @@ public async Task PrepareCallAttachmentWriteAsync(int depa () => attachment.IsProtected = true, cancellationToken); } + public async Task PrepareCertificationWriteAsync(int departmentId, + PersonnelCertification certification, PersonnelCertification existingCertification, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (certification == null) + return ProtectedWriteResult.Allowed(); + + var slots = new List(); + var rowKey = certification.PersonnelCertificationId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in CertificationFieldAccessors) + { + var value = accessor.Value.Get(certification); + + // The sentinel is what an unrevealed form posts back. Restore the stored value rather + // than encrypting the literal "REDACTED" over the member's real certification data. + if (value == ProtectedDataEnvelope.RedactionValue) + { + if (existingCertification != null) + accessor.Value.Set(certification, accessor.Value.Get(existingCertification)); + continue; + } + + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(certification, envelope) }); + } + + // A null document on an edit means "no new file was uploaded", not "delete the file" — + // keep whatever is stored, which for a protected department is already an envelope. + if ((certification.Data == null || certification.Data.Length == 0) && existingCertification != null) + certification.Data = existingCertification.Data; + + if (certification.Data != null && certification.Data.Length > 0 && !IsBinaryEnveloped(certification.Data)) + slots.Add(new WriteSlot + { + FieldId = CertificationDataFieldId, + RowKey = rowKey, + IsBinary = true, + WireValue = Convert.ToBase64String(certification.Data), + Apply = envelopeBase64 => certification.Data = Convert.FromBase64String(envelopeBase64) + }); + + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => certification.IsProtected = true, cancellationToken); + } + public async Task PrepareContactWriteAsync(int departmentId, Contact contact, Contact existingContact, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) { @@ -534,6 +935,75 @@ public async Task PrepareContactNoteWriteAsync(int departm return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); } + public async Task PrepareUnitStateWriteAsync(int departmentId, UnitState state, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (state == null) + return ProtectedWriteResult.Allowed(); + + var slots = new List(); + var rowKey = state.UnitStateId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in UnitStateFieldAccessors) + { + var value = accessor.Value.Get(state); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(state, envelope) }); + } + + // Companion columns: the typed coordinate moves into its envelope column and the typed + // column is nulled — the migration engine's exact write shape (plan 22.3). + if (state.Latitude.HasValue) + slots.Add(new WriteSlot + { + FieldId = "unitstates.latitude", + RowKey = rowKey, + WireValue = state.Latitude.Value.ToString(CultureInfo.InvariantCulture), + Apply = envelope => { state.ProtectedLatitudeEnvelope = envelope; state.Latitude = null; } + }); + if (state.Longitude.HasValue) + slots.Add(new WriteSlot + { + FieldId = "unitstates.longitude", + RowKey = rowKey, + WireValue = state.Longitude.Value.ToString(CultureInfo.InvariantCulture), + Apply = envelope => { state.ProtectedLongitudeEnvelope = envelope; state.Longitude = null; } + }); + + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => state.IsProtected = true, cancellationToken); + } + + public async Task PrepareUdfFieldValueWriteAsync(int departmentId, UdfFieldValue value, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (value == null) + return ProtectedWriteResult.Allowed(); + + var slots = new List(); + foreach (var accessor in UdfFieldValueAccessors) + { + var stored = accessor.Value.Get(value); + if (string.IsNullOrEmpty(stored) || ProtectedDataEnvelope.HasEnvelopePrefix(stored) || + stored == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot + { + FieldId = accessor.Key, + RowKey = value.UdfFieldValueId, + WireValue = stored, + Apply = envelope => set(value, envelope) + }); + } + + return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + } + /// /// REDACTED-sentinel restore source for PrepareContactWriteAsync (mirrors /// SnapshotCatalogedCallFields for the MVC contact edit surface). @@ -599,6 +1069,26 @@ private async Task EncryptSlotsAsync(int departmentId, str var policyRow = await _dataProtectionService.GetPolicyByDepartmentIdAsync(departmentId); var catalogVersion = policyRow?.CatalogVersion ?? 0; + // The department's PINNED catalog version decides what it owns. A field added in a later + // catalog is not encrypted until a catalog upgrade sweeps that department: encrypting it + // early would write an envelope no migration ever accounted for, under this department's + // older AAD. Skipped fields stay plaintext and are picked up by the upgrade. + var ownedFieldIds = new HashSet( + _fieldCatalog.GetAllForVersion(catalogVersion).Select(e => e.FieldId), + StringComparer.OrdinalIgnoreCase); + var skipped = slots.Where(x => !ownedFieldIds.Contains(x.FieldId)).ToList(); + if (skipped.Count > 0) + { + Logging.LogInfo($"ADP: department {departmentId} is pinned at catalog v{catalogVersion}; skipping {skipped.Count} field(s) added in a later catalog until an upgrade runs."); + slots = slots.Where(x => ownedFieldIds.Contains(x.FieldId)).ToList(); + + if (slots.Count == 0) + { + markProtected?.Invoke(); + return ProtectedWriteResult.Allowed(isProtected: true); + } + } + var items = slots.Select(s => new ProtectedFieldOperationItem { FieldId = s.FieldId, @@ -693,6 +1183,69 @@ private static void CollectCallSlots(ProtectedReadResult owner, List slots } } + private static void CollectUnitStateSlots(ProtectedReadResult owner, UnitState state, List slots) + { + var rowKey = state.UnitStateId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in UnitStateFieldAccessors) + { + var value = accessor.Value.Get(state); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = owner, + Reveal = plaintext => set(state, plaintext), + Redact = () => set(state, ProtectedDataEnvelope.RedactionValue) + }); + } + + foreach (var accessor in UnitStateCompanionAccessors) + { + var envelope = accessor.Value.GetEnvelope(state); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(envelope)) + continue; + + var setTyped = accessor.Value.SetTyped; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = envelope, + Owner = owner, + Reveal = plaintext => setTyped(state, + decimal.TryParse(plaintext, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed) ? parsed : null), + Redact = () => setTyped(state, null) + }); + } + } + + private static void CollectUdfFieldValueSlots(ProtectedReadResult owner, UdfFieldValue value, List slots) + { + var rowKey = value.UdfFieldValueId; + foreach (var accessor in UdfFieldValueAccessors) + { + var stored = accessor.Value.Get(value); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(stored)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = stored, + Owner = owner, + Reveal = plaintext => set(value, plaintext), + Redact = () => set(value, ProtectedDataEnvelope.RedactionValue) + }); + } + } + private static void CollectNoteSlots(ProtectedReadResult owner, CallNote note, List slots) { var rowKey = note.CallNoteId.ToString(CultureInfo.InvariantCulture); @@ -799,6 +1352,50 @@ private static void CollectAttachmentSlots(ProtectedReadResult owner, CallAttach } } + private static void CollectCertificationSlots(ProtectedReadResult owner, PersonnelCertification certification, + List slots, bool includeData) + { + var rowKey = certification.PersonnelCertificationId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in CertificationFieldAccessors) + { + var value = accessor.Value.Get(certification); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = owner, + Reveal = plaintext => set(certification, plaintext), + Redact = () => set(certification, ProtectedDataEnvelope.RedactionValue) + }); + } + + if (includeData && IsBinaryEnveloped(certification.Data)) + { + slots.Add(new Slot + { + FieldId = CertificationDataFieldId, + RowKey = rowKey, + IsBinary = true, + WireValue = Convert.ToBase64String(certification.Data), + Owner = owner, + Reveal = base64 => certification.Data = Convert.FromBase64String(base64), + // A concealed document is NULL — ciphertext bytes are never served. + Redact = () => certification.Data = null + }); + } + else if (!includeData && IsBinaryEnveloped(certification.Data)) + { + // Metadata-only resolution: strip the ciphertext so a serializer cannot carry it out; + // the download endpoint re-fetches and opts into decryption. + certification.Data = null; + } + } + // ── shared resolution core ─────────────────────────────────────────────── private async Task ResolveSlotsAsync(int departmentId, string grantToken, string userId, diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 2ca4e0ff9..f669971d4 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -46,6 +46,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().SingleInstance(); + // ADP outbound net (plan 7.5): a push title or subtitle carrying an envelope would land + // on a lock screen and in the OS notification log. + builder.RegisterDecorator(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); @@ -170,6 +173,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); // Attended protected reads + the write safety net. Requires IProtectedDataBrokerClient, // so every composition root that loads this module must also load // ProtectedDataBrokerClientModule (client only — no key material). diff --git a/Core/Resgrid.Services/UdfRenderingService.cs b/Core/Resgrid.Services/UdfRenderingService.cs index c8e8f0267..0430afc40 100644 --- a/Core/Resgrid.Services/UdfRenderingService.cs +++ b/Core/Resgrid.Services/UdfRenderingService.cs @@ -16,7 +16,7 @@ public string GenerateHtmlFormFields(UdfDefinition definition, List fi return string.Empty; var valueMap = (existingValues ?? new List()) - .ToDictionary(v => v.UdfFieldId, v => v.Value ?? string.Empty); + .ToDictionary(v => v.UdfFieldId, v => ProtectedDataEnvelope.SafeDisplay(v.Value) ?? string.Empty); var sb = new StringBuilder(); sb.AppendLine($"
"); @@ -57,7 +57,7 @@ public string GenerateReactNativeSchema(UdfDefinition definition, List return JsonConvert.SerializeObject(new { definitionId = (string)null, entityType = 0, fields = new object[0] }); var valueMap = (existingValues ?? new List()) - .ToDictionary(v => v.UdfFieldId, v => v.Value); + .ToDictionary(v => v.UdfFieldId, v => ProtectedDataEnvelope.SafeDisplay(v.Value)); var schemaFields = fields .Where(f => f.IsEnabled && f.IsVisibleOnMobile) @@ -119,7 +119,7 @@ public string GenerateReadOnlyHtml(UdfDefinition definition, List fiel return string.Empty; var valueMap = (values ?? new List()) - .ToDictionary(v => v.UdfFieldId, v => v.Value ?? string.Empty); + .ToDictionary(v => v.UdfFieldId, v => ProtectedDataEnvelope.SafeDisplay(v.Value) ?? string.Empty); var sb = new StringBuilder(); sb.AppendLine($"
"); diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs index 33ae8c572..d7101f92e 100644 --- a/Core/Resgrid.Services/UnitsService.cs +++ b/Core/Resgrid.Services/UnitsService.cs @@ -32,13 +32,17 @@ public class UnitsService : IUnitsService private readonly ILimitsService _limitsService; private readonly IPersonnelRolesService _personnelRolesService; + // Lazy: defers the protected-write graph (broker client) until a state save actually needs it. + private readonly Lazy _protectedWriteService; + public UnitsService(IUnitsRepository unitsRepository, IUnitStatesRepository unitStatesRepository, IUnitLogsRepository unitLogsRepository, IUnitTypesRepository unitTypesRepository, ISubscriptionsService subscriptionsService, IUnitRolesRepository unitRolesRepository, IUnitStateRoleRepository unitStateRoleRepository, IUserStateService userStateService, IEventAggregator eventAggregator, ICustomStateService customStateService, Lazy> unitLocationRepository, IUnitLocationsDocRepository unitLocationsDocRepository, Lazy unitLocationsMongoRepository, IUnitActiveRolesRepository unitActiveRolesRepository, - IDepartmentGroupsService departmentGroupsService, ILimitsService limitsService, IPersonnelRolesService personnelRolesService) + IDepartmentGroupsService departmentGroupsService, ILimitsService limitsService, IPersonnelRolesService personnelRolesService, + Lazy protectedWriteService) { _unitsRepository = unitsRepository; _unitStatesRepository = unitStatesRepository; @@ -57,6 +61,7 @@ public UnitsService(IUnitsRepository unitsRepository, IUnitStatesRepository unit _departmentGroupsService = departmentGroupsService; _limitsService = limitsService; _personnelRolesService = personnelRolesService; + _protectedWriteService = protectedWriteService; } public async Task> GetAllAsync() @@ -343,6 +348,17 @@ public async Task GetUnitTypeByNameAsync(int departmentId, string type var saved = await _unitStatesRepository.SaveOrUpdateAsync(state, cancellationToken); + // ADP write safety net (plan 4.2/19.2), catalog v2: unit-state note, geolocation and the + // typed coordinates are cataloged operational fields. Runs AFTER the save so the identity + // pk exists (it is the AAD row key), then re-persists the enveloped row. Every caller — + // v4 API, apps, unit tracking ingress, workers — is covered here rather than per-caller. + var protectedWrite = await _protectedWriteService.Value.PrepareUnitStateWriteAsync(departmentId, + saved, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); unit state {saved.UnitStateId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _unitStatesRepository.SaveOrUpdateAsync(saved, cancellationToken); + _eventAggregator.SendMessage(new UnitStatusEvent { DepartmentId = departmentId, Status = saved, PreviousStatus = previousState }); return saved; @@ -375,9 +391,20 @@ public async Task GetUnitTypeByNameAsync(int departmentId, string type var saved = await _unitStatesRepository.SaveOrUpdateAsync(state, cancellationToken); + // ADP write safety net (plan 4.2/19.2), catalog v2: unit-state note, geolocation and the + // typed coordinates are cataloged operational fields. Runs AFTER the save so the identity + // pk exists (it is the AAD row key), then re-persists the enveloped row. Every caller — + // v4 API, apps, unit tracking ingress, workers — is covered here rather than per-caller. + var protectedWrite = await _protectedWriteService.Value.PrepareUnitStateWriteAsync(departmentId, + saved, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); unit state {saved.UnitStateId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _unitStatesRepository.SaveOrUpdateAsync(saved, cancellationToken); + _eventAggregator.SendMessage(new UnitStatusEvent { DepartmentId = departmentId, Status = saved, PreviousStatus = previousState, AutoGenerated = autoGenerated }); - return state; + return saved; } public async Task> GetLogsForUnitAsync(int unitId) diff --git a/Core/Resgrid.Services/WorkLogsService.cs b/Core/Resgrid.Services/WorkLogsService.cs index 79ca74f30..a01e9a0c2 100644 --- a/Core/Resgrid.Services/WorkLogsService.cs +++ b/Core/Resgrid.Services/WorkLogsService.cs @@ -20,9 +20,13 @@ public class WorkLogsService : IWorkLogsService private readonly IDepartmentGroupsService _departmentGroupsService; private readonly ICallsService _callsService; + // Lazy: defers the protected-write graph (broker client) until a log save actually needs it. + private readonly Lazy _protectedWriteService; + public WorkLogsService(ILogsRepository logsRepository, ICallLogsRepository callLogsRepository, ILogUsersRepository logUsersRepository, ILogAttachmentRepository logAttachmentRepository, ILogUnitsRepository logUnitsRepository, IDepartmentsService departmentsService, - IDepartmentGroupsService departmentGroupsService, ICallsService callsService) + IDepartmentGroupsService departmentGroupsService, ICallsService callsService, + Lazy protectedWriteService) { _logsRepository = logsRepository; _callLogsRepository = callLogsRepository; @@ -31,6 +35,7 @@ public WorkLogsService(ILogsRepository logsRepository, ICallLogsRepository callL _logUnitsRepository = logUnitsRepository; _departmentsService = departmentsService; _departmentGroupsService = departmentGroupsService; + _protectedWriteService = protectedWriteService; _callsService = callsService; } @@ -116,6 +121,17 @@ public async Task GetCallLogByIdAsync(int callLogId) var savedLogUser = _logUsersRepository.SaveOrUpdateAsync(user, cancellationToken, true); } + // ADP write safety net (plan 4.2/19.2), catalog v3: the incident log's narrative, initial + // report, cause, contact details, location and body/deceased fields are cataloged. Runs + // AFTER the save so the identity pk exists (it is the AAD row key), then re-persists the + // enveloped row. Fails closed by throwing rather than leaving plaintext at rest. + var protectedWrite = await _protectedWriteService.Value.PrepareLogWriteAsync(savedLog.DepartmentId, + savedLog, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); log {savedLog.LogId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + savedLog = await _logsRepository.SaveOrUpdateAsync(savedLog, cancellationToken, true); + return savedLog; } @@ -163,7 +179,19 @@ public async Task> GetAllLogsByDepartmentDateRangeAsync(int department { log.LoggedOn = DateTime.UtcNow; - return await _callLogsRepository.SaveOrUpdateAsync(log, cancellationToken); + var savedLog = await _callLogsRepository.SaveOrUpdateAsync(log, cancellationToken); + + // ADP write safety net (plan 4.2/19.2): calllogs.narrative is cataloged. Runs AFTER the + // save so the identity pk exists (it is the AAD row key), then re-persists the enveloped + // row. Fails closed by throwing rather than leaving plaintext at rest. + var protectedWrite = await _protectedWriteService.Value.PrepareCallLogWriteAsync(savedLog.DepartmentId, + savedLog, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); call log {savedLog.CallLogId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + savedLog = await _callLogsRepository.SaveOrUpdateAsync(savedLog, cancellationToken); + + return savedLog; } public async Task DeleteCallLogAsync(int callLogId, CancellationToken cancellationToken = default(CancellationToken)) diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index 4c96a487f..3a6ed4f04 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -26,6 +26,7 @@ public class WorkflowTemplateContextBuilder : IWorkflowTemplateContextBuilder private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IPersonnelRolesService _personnelRolesService; private readonly IUnitsService _unitsService; + private readonly IDepartmentMemberSensitiveDataService _memberSensitiveDataService; public WorkflowTemplateContextBuilder( IDepartmentsService departmentsService, @@ -33,7 +34,8 @@ public WorkflowTemplateContextBuilder( IUserProfileService userProfileService, IDepartmentGroupsService departmentGroupsService, IPersonnelRolesService personnelRolesService, - IUnitsService unitsService) + IUnitsService unitsService, + IDepartmentMemberSensitiveDataService memberSensitiveDataService) { _departmentsService = departmentsService; _departmentSettingsService = departmentSettingsService; @@ -41,6 +43,7 @@ public WorkflowTemplateContextBuilder( _departmentGroupsService = departmentGroupsService; _personnelRolesService = personnelRolesService; _unitsService = unitsService; + _memberSensitiveDataService = memberSensitiveDataService; } public async Task BuildContextAsync( @@ -395,7 +398,7 @@ public async Task BuildContextAsync( } } - await AddCommonUserVariablesAsync(scriptObject, triggeringUserId); + await AddCommonUserVariablesAsync(scriptObject, departmentId, triggeringUserId); return scriptObject; } @@ -525,12 +528,18 @@ object Scalar(params string[] names) return incident["user_id"]?.ToString(); } - private async Task AddCommonUserVariablesAsync(ScriptObject obj, string userId) + private async Task AddCommonUserVariablesAsync(ScriptObject obj, int departmentId, string userId) { var u = new ScriptObject(); if (!string.IsNullOrWhiteSpace(userId)) { var profile = await _userProfileService.GetProfileByUserIdAsync(userId); + + // The identification number is department-scoped and protected (plan 5.1). Workflow + // variables feed outbound email, SMS and webhooks with no reveal step, so a protected + // department renders the placeholder rather than the number — and the legacy global + // column is not consulted at all, since it answers for the wrong department. + var sensitive = await _memberSensitiveDataService.GetByDepartmentAndUserAsync(departmentId, userId); u["id"] = userId; u["first_name"] = profile?.FirstName ?? string.Empty; u["last_name"] = profile?.LastName ?? string.Empty; @@ -538,7 +547,7 @@ private async Task AddCommonUserVariablesAsync(ScriptObject obj, string userId) u["email"] = profile?.MembershipEmail ?? string.Empty; u["mobile_number"] = profile?.MobileNumber ?? string.Empty; u["home_number"] = profile?.HomeNumber ?? string.Empty; - u["identification_number"] = profile?.IdentificationNumber ?? string.Empty; + u["identification_number"] = ProtectedDataEnvelope.SafeDisplay(sensitive?.IdentificationNumber) ?? string.Empty; u["username"] = string.Empty; // populated from IdentityUser if needed u["time_zone"] = profile?.TimeZone ?? string.Empty; } @@ -566,14 +575,18 @@ private async Task MapCallVariablesAsync(ScriptObject obj, Call call, int depart var c = new ScriptObject(); c["id"] = call.CallId; c["number"] = call.Number ?? string.Empty; - c["name"] = call.Name ?? string.Empty; - c["nature"] = call.NatureOfCall ?? string.Empty; - c["notes"] = call.Notes ?? string.Empty; - c["address"] = call.Address ?? string.Empty; - c["geo_location"] = call.GeoLocationData ?? string.Empty; - c["type"] = call.Type ?? string.Empty; - c["incident_number"] = call.IncidentNumber ?? string.Empty; - c["reference_number"] = call.ReferenceNumber ?? string.Empty; + // ADP (plan section 8): workflows render these into outbound email/SMS/webhooks and run + // UNATTENDED — no grant can exist here, so a protected department's cataloged values must + // degrade to the REDACTED placeholder. Ciphertext must never reach a template, and the + // system-generated call number stays plaintext as the safe identifier. + c["name"] = ProtectedDataEnvelope.SafeDisplay(call.Name) ?? string.Empty; + c["nature"] = ProtectedDataEnvelope.SafeDisplay(call.NatureOfCall) ?? string.Empty; + c["notes"] = ProtectedDataEnvelope.SafeDisplay(call.Notes) ?? string.Empty; + c["address"] = ProtectedDataEnvelope.SafeDisplay(call.Address) ?? string.Empty; + c["geo_location"] = ProtectedDataEnvelope.SafeDisplay(call.GeoLocationData) ?? string.Empty; + c["type"] = ProtectedDataEnvelope.SafeDisplay(call.Type) ?? string.Empty; + c["incident_number"] = ProtectedDataEnvelope.SafeDisplay(call.IncidentNumber) ?? string.Empty; + c["reference_number"] = ProtectedDataEnvelope.SafeDisplay(call.ReferenceNumber) ?? string.Empty; c["map_page"] = call.MapPage ?? string.Empty; c["priority"] = call.Priority; c["priority_text"] = call.GetPriorityText(); @@ -581,18 +594,18 @@ private async Task MapCallVariablesAsync(ScriptObject obj, Call call, int depart c["state"] = call.State; c["state_text"] = call.GetStateText(); c["source"] = call.CallSource; - c["external_id"] = call.ExternalIdentifier ?? string.Empty; + c["external_id"] = ProtectedDataEnvelope.SafeDisplay(call.ExternalIdentifier) ?? string.Empty; c["logged_on"] = call.LoggedOn; c["closed_on"] = call.ClosedOn; - c["completed_notes"] = call.CompletedNotes ?? string.Empty; - c["contact_name"] = call.ContactName ?? string.Empty; - c["contact_number"] = call.ContactNumber ?? string.Empty; - c["w3w"] = call.W3W ?? string.Empty; + c["completed_notes"] = ProtectedDataEnvelope.SafeDisplay(call.CompletedNotes) ?? string.Empty; + c["contact_name"] = ProtectedDataEnvelope.SafeDisplay(call.ContactName) ?? string.Empty; + c["contact_number"] = ProtectedDataEnvelope.SafeDisplay(call.ContactNumber) ?? string.Empty; + c["w3w"] = ProtectedDataEnvelope.SafeDisplay(call.W3W) ?? string.Empty; c["dispatch_count"] = call.DispatchCount; c["dispatch_on"] = call.DispatchOn; - c["form_data"] = call.CallFormData ?? string.Empty; + c["form_data"] = ProtectedDataEnvelope.SafeDisplay(call.CallFormData) ?? string.Empty; c["is_deleted"] = call.IsDeleted; - c["deleted_reason"] = call.DeletedReason ?? string.Empty; + c["deleted_reason"] = ProtectedDataEnvelope.SafeDisplay(call.DeletedReason) ?? string.Empty; // ── Personnel dispatches ────────────────────────────────────────────────── // Pre-fetch all user profiles and group/role data needed for enrichment @@ -601,10 +614,16 @@ private async Task MapCallVariablesAsync(ScriptObject obj, Call call, int depart : new List(); Dictionary profileMap = new Dictionary(); + IReadOnlyDictionary sensitiveByUser = + new Dictionary(); if (dispatchUserIds.Count > 0) { var profiles = await _userProfileService.GetSelectedUserProfilesAsync(dispatchUserIds); profileMap = profiles?.ToDictionary(p => p.UserId, p => p) ?? new Dictionary(); + + // Identification numbers are department-scoped and protected (plan 5.1); this context + // has no reveal step, so they render as the placeholder for a protected department. + sensitiveByUser = await _memberSensitiveDataService.GetResolvedForDepartmentAsync(departmentId, null, null); } // Pre-fetch department roles once (used for role-name enrichment below) @@ -655,7 +674,9 @@ private async Task MapCallVariablesAsync(ScriptObject obj, Call call, int depart item["full_name"] = profile.FullName?.AsFirstNameLastName ?? string.Empty; item["email"] = profile.MembershipEmail ?? string.Empty; item["mobile_number"] = profile.MobileNumber ?? string.Empty; - item["identification_number"] = profile.IdentificationNumber ?? string.Empty; + item["identification_number"] = sensitiveByUser.TryGetValue(d.UserId, out var sensitive) + ? ProtectedDataEnvelope.SafeDisplay(sensitive.IdentificationNumber) ?? string.Empty + : string.Empty; } else { @@ -786,7 +807,7 @@ private async Task MapCallVariablesAsync(ScriptObject obj, Call call, int depart foreach (var n in call.CallNotes) { var item = new ScriptObject(); - item["note"] = n.Note ?? string.Empty; + item["note"] = ProtectedDataEnvelope.SafeDisplay(n.Note) ?? string.Empty; item["source"] = n.Source.ToString(); item["timestamp"] = n.Timestamp; item["user_id"] = n.UserId ?? string.Empty; @@ -822,7 +843,7 @@ private static void MapUnitStatusVariables(ScriptObject obj, UnitState status, U s["state"] = status.State; s["state_text"] = status.GetStatusText(); s["timestamp"] = status.Timestamp; - s["note"] = status.Note ?? string.Empty; + s["note"] = ProtectedDataEnvelope.SafeDisplay(status.Note) ?? string.Empty; s["latitude"] = status.Latitude; s["longitude"] = status.Longitude; s["destination_id"] = status.DestinationId; @@ -883,9 +904,9 @@ private static void MapPersonnelStatusVariables(ScriptObject obj, ActionLog stat s["action_type"] = status.ActionTypeId; s["action_text"] = status.GetActionText(); s["timestamp"] = status.Timestamp; - s["geo_location"] = status.GeoLocationData ?? string.Empty; + s["geo_location"] = ProtectedDataEnvelope.SafeDisplay(status.GeoLocationData) ?? string.Empty; s["destination_id"] = status.DestinationId; - s["note"] = status.Note ?? string.Empty; + s["note"] = ProtectedDataEnvelope.SafeDisplay(status.Note) ?? string.Empty; } obj["status"] = s; @@ -1065,13 +1086,17 @@ private static void MapInventoryVariables(ScriptObject obj, Inventory inventory, private static void MapCertificationVariables(ScriptObject obj, PersonnelCertification cert, int daysUntilExpiry) { + // Cataloged since v6 (plan 5.1). Workflow variables feed outbound email, SMS and + // webhooks with no reveal step, so a protected department's certification renders as the + // placeholder — an expiry reminder can still say a certification is due without naming + // the licence number. var c = new ScriptObject(); c["id"] = cert.PersonnelCertificationId; - c["name"] = cert.Name ?? string.Empty; - c["number"] = cert.Number ?? string.Empty; - c["type"] = cert.Type ?? string.Empty; - c["area"] = cert.Area ?? string.Empty; - c["issued_by"] = cert.IssuedBy ?? string.Empty; + c["name"] = ProtectedDataEnvelope.SafeDisplay(cert.Name) ?? string.Empty; + c["number"] = ProtectedDataEnvelope.SafeDisplay(cert.Number) ?? string.Empty; + c["type"] = ProtectedDataEnvelope.SafeDisplay(cert.Type) ?? string.Empty; + c["area"] = ProtectedDataEnvelope.SafeDisplay(cert.Area) ?? string.Empty; + c["issued_by"] = ProtectedDataEnvelope.SafeDisplay(cert.IssuedBy) ?? string.Empty; c["expires_on"] = cert.ExpiresOn; c["received_on"] = cert.RecievedOn; c["days_until_expiry"] = daysUntilExpiry; diff --git a/Providers/Resgrid.Providers.Email/EmailProviderModule.cs b/Providers/Resgrid.Providers.Email/EmailProviderModule.cs index 6da253801..9392eb2b7 100644 --- a/Providers/Resgrid.Providers.Email/EmailProviderModule.cs +++ b/Providers/Resgrid.Providers.Email/EmailProviderModule.cs @@ -11,6 +11,9 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // ADP outbound net (plan 7.5): scrubs envelopes out of any email whose sender skipped + // its protected projection. Wraps whichever sender is registered above. + builder.RegisterDecorator(); builder.RegisterType().As().InstancePerLifetimeScope(); } } diff --git a/Providers/Resgrid.Providers.Email/ProtectedEmailSenderDecorator.cs b/Providers/Resgrid.Providers.Email/ProtectedEmailSenderDecorator.cs new file mode 100644 index 000000000..a4b8737f2 --- /dev/null +++ b/Providers/Resgrid.Providers.Email/ProtectedEmailSenderDecorator.cs @@ -0,0 +1,154 @@ +using System; +using System.Linq; +using System.Net.Mail; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.EmailProvider +{ + /// + /// Outbound-boundary net for email (ADP plan section 7.5, queue side). Every message leaving + /// the platform passes through , so wrapping it covers every sender — + /// dispatch, notifications, reports, exports, workers — without a per-caller list. + /// + /// Notification paths are supposed to call IProtectedProjectionService first, which + /// produces a properly worded safe message. This catches the ones that did not: it scrubs the + /// ciphertext, drops an enveloped attachment, and logs enough to find the missed path. It never + /// blocks the send — a degraded dispatch email is bad, an undelivered one is dangerous. + /// + public class ProtectedEmailSenderDecorator : IEmailSender + { + private readonly IEmailSender _inner; + + public ProtectedEmailSenderDecorator(IEmailSender inner) + { + _inner = inner; + } + + public async Task SendEmail(MailMessage email) + { + try + { + Sanitize(email); + } + catch (Exception ex) + { + // The net must never be the reason a message fails to send. + Logging.LogException(ex, "ProtectedEmailSenderDecorator failed while sanitizing an outbound email"); + } + + return await _inner.SendEmail(email); + } + + public async Task Send(Email email) + { + try + { + Sanitize(email); + } + catch (Exception ex) + { + Logging.LogException(ex, "ProtectedEmailSenderDecorator failed while sanitizing an outbound email"); + } + + return await _inner.Send(email); + } + + /// + /// Composition only — the message it builds is sanitized when it is actually sent, so + /// scrubbing here as well would only double the work. + /// + public MailMessage CreateMailMessageFromEmail(Email email) => _inner.CreateMailMessageFromEmail(email); + + private static void Sanitize(MailMessage email) + { + if (email == null) + return; + + var scrubbed = 0; + + email.Subject = ProtectedOutboundGuard.Scrub(email.Subject, out var subjectCount); + scrubbed += subjectCount; + + email.Body = ProtectedOutboundGuard.Scrub(email.Body, out var bodyCount); + scrubbed += bodyCount; + + // An attachment whose bytes are still an envelope is unreadable to the recipient and is + // raw ciphertext sitting in their mailbox. There is nothing to redact inside a file. + var enveloped = email.Attachments + .Where(a => a?.ContentStream != null && StartsWithBinaryEnvelope(a.ContentStream)) + .ToList(); + + foreach (var attachment in enveloped) + { + email.Attachments.Remove(attachment); + attachment.Dispose(); + } + + Report(scrubbed, enveloped.Count, email.To?.Count ?? 0); + } + + private static void Sanitize(Email email) + { + if (email == null) + return; + + var scrubbed = 0; + + email.Subject = ProtectedOutboundGuard.Scrub(email.Subject, out var subjectCount); + scrubbed += subjectCount; + + email.HtmlBody = ProtectedOutboundGuard.Scrub(email.HtmlBody, out var htmlCount); + scrubbed += htmlCount; + + email.TextBody = ProtectedOutboundGuard.Scrub(email.TextBody, out var textCount); + scrubbed += textCount; + + Report(scrubbed, 0, email.To?.Count ?? 0); + } + + private static void Report(int scrubbed, int droppedAttachments, int recipients) + { + if (scrubbed == 0 && droppedAttachments == 0) + return; + + // Recipients and content stay out of the log; that a send path skipped its safe + // projection is the finding, and the counts are enough to locate it. + Logging.LogError($"ADP outbound net scrubbed an email before sending: {scrubbed} enveloped value(s) in subject/body, " + + $"{droppedAttachments} enveloped attachment(s) dropped, {recipients} recipient(s). " + + "A notification path is missing its protected projection."); + } + + private static bool StartsWithBinaryEnvelope(System.IO.Stream stream) + { + if (!stream.CanSeek || !stream.CanRead) + return false; + + var prefix = System.Text.Encoding.ASCII.GetBytes(ProtectedDataEnvelope.BinaryPrefix); + var buffer = new byte[prefix.Length]; + + var position = stream.Position; + try + { + stream.Position = 0; + var read = stream.Read(buffer, 0, buffer.Length); + if (read < buffer.Length) + return false; + + for (var i = 0; i < prefix.Length; i++) + { + if (buffer[i] != prefix[i]) + return false; + } + + return true; + } + finally + { + stream.Position = position; + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0131_AddDepartmentMemberEmergencyContacts.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0131_AddDepartmentMemberEmergencyContacts.cs new file mode 100644 index 000000000..fc3dd93de --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0131_AddDepartmentMemberEmergencyContacts.cs @@ -0,0 +1,69 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Department-scoped emergency contacts for a member (ADP plan section 5.1). A member can have + /// MORE THAN ONE, and the set is per department: UserProfile is global to the user and shared + /// across every department they belong to, so it can neither be encrypted with one department's + /// key nor hold values that legitimately differ between departments. + /// + /// Also drops the single-value EmergencyContactName/Phone columns M0124 speculatively added to + /// DepartmentMemberSensitiveData. Those were never populated, never surfaced in any UI and never + /// read by any service — this table supersedes them. + /// + [Migration(131)] + public class M0131_AddDepartmentMemberEmergencyContacts : Migration + { + public override void Up() + { + if (!Schema.Table("DepartmentMemberEmergencyContacts").Exists()) + { + Create.Table("DepartmentMemberEmergencyContacts") + .WithColumn("DepartmentMemberEmergencyContactId").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("DepartmentId").AsInt32().NotNullable() + .WithColumn("UserId").AsString(128).NotNullable() + .WithColumn("Name").AsString(int.MaxValue).Nullable() + .WithColumn("Relationship").AsString(int.MaxValue).Nullable() + .WithColumn("PhoneNumber").AsString(int.MaxValue).Nullable() + .WithColumn("AlternatePhoneNumber").AsString(int.MaxValue).Nullable() + .WithColumn("Email").AsString(int.MaxValue).Nullable() + .WithColumn("Notes").AsString(int.MaxValue).Nullable() + .WithColumn("IsPrimary").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("SortOrder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false) + // ADP row marker: set once the row's cataloged columns carry rgdp envelopes. + .WithColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("CreatedOn").AsDateTime().NotNullable() + .WithColumn("CreatedByUserId").AsString(128).Nullable() + .WithColumn("UpdatedOn").AsDateTime().Nullable() + .WithColumn("UpdatedByUserId").AsString(128).Nullable(); + + Create.Index("IX_DepartmentMemberEmergencyContacts_Department_User") + .OnTable("DepartmentMemberEmergencyContacts") + .OnColumn("DepartmentId").Ascending() + .OnColumn("UserId").Ascending(); + } + + // Superseded by the table above; never populated or read. + if (Schema.Table("DepartmentMemberSensitiveData").Column("EmergencyContactName").Exists()) + Delete.Column("EmergencyContactName").FromTable("DepartmentMemberSensitiveData"); + + if (Schema.Table("DepartmentMemberSensitiveData").Column("EmergencyContactPhone").Exists()) + Delete.Column("EmergencyContactPhone").FromTable("DepartmentMemberSensitiveData"); + } + + public override void Down() + { + // Only safe while every department is Disabled: dropping this table on a protected + // department destroys emergency-contact ciphertext that cannot be recovered. + if (Schema.Table("DepartmentMemberEmergencyContacts").Exists()) + Delete.Table("DepartmentMemberEmergencyContacts"); + + if (!Schema.Table("DepartmentMemberSensitiveData").Column("EmergencyContactName").Exists()) + Alter.Table("DepartmentMemberSensitiveData") + .AddColumn("EmergencyContactName").AsString(int.MaxValue).Nullable() + .AddColumn("EmergencyContactPhone").AsString(int.MaxValue).Nullable(); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0132_BackfillMemberIdentificationNumbers.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0132_BackfillMemberIdentificationNumbers.cs new file mode 100644 index 000000000..0ce0ed5d8 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0132_BackfillMemberIdentificationNumbers.cs @@ -0,0 +1,43 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Moves member identification numbers from the GLOBAL UserProfiles row to the department-scoped + /// DepartmentMemberSensitiveData row (ADP plan section 5.1). A profile is shared across every + /// department a user belongs to, so it can neither be encrypted with one department's key nor + /// hold the different badge/ID numbers different departments issue the same person. + /// + /// EXPAND phase of expand/contract: this backfills a row per (department, member) that actually + /// has a number and leaves UserProfiles.IdentificationNumber in place. The application reads and + /// writes the department-scoped value from here on; the column is dropped in a later migration + /// once this is deployed and verified, so a rollback does not lose data. + /// + [Migration(132)] + public class M0132_BackfillMemberIdentificationNumbers : Migration + { + public override void Up() + { + // Only members with a number, and only where no row exists yet — the migration is + // re-runnable and never overwrites a department-specific value someone already set. + Execute.Sql(@" +INSERT INTO [DepartmentMemberSensitiveData] ([DepartmentId], [UserId], [ProtectionId], [IdentificationNumber], [IsProtected], [CreatedOn]) +SELECT dm.[DepartmentId], dm.[UserId], LOWER(REPLACE(CONVERT(NVARCHAR(64), NEWID()), '-', '')), up.[IdentificationNumber], 0, GETUTCDATE() +FROM [DepartmentMembers] dm +INNER JOIN [UserProfiles] up ON up.[UserId] = dm.[UserId] +WHERE dm.[IsDeleted] = 0 + AND up.[IdentificationNumber] IS NOT NULL + AND LTRIM(RTRIM(up.[IdentificationNumber])) <> '' + AND NOT EXISTS ( + SELECT 1 FROM [DepartmentMemberSensitiveData] s + WHERE s.[DepartmentId] = dm.[DepartmentId] AND s.[UserId] = dm.[UserId]);"); + } + + public override void Down() + { + // The rows may since have been edited per department, or encrypted for a protected + // department — copying them back into the shared profile would be wrong in both cases. + // The source column was never cleared, so there is nothing to restore. + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0133_AddMemberDepartmentAddresses.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0133_AddMemberDepartmentAddresses.cs new file mode 100644 index 000000000..37439c51c --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0133_AddMemberDepartmentAddresses.cs @@ -0,0 +1,69 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Department-scoped member addresses (ADP plan section 5.1). Stored as columns on + /// DepartmentMemberSensitiveData rather than as a foreign key to the shared Addresses table: + /// an Addresses row has no owner, is reachable from profiles, contacts, departments and + /// stations alike, and encrypting one with a single department's key would break every other + /// reader. That shared-ownership problem is exactly what the plan defers — sidestepped here by + /// giving the member's address its own department-scoped storage, which also lets a member hold + /// a different address per department. + /// + /// EXPAND phase: the legacy UserProfiles.HomeAddressId/MailingAddressId links are left intact + /// and backfilled from; the contract migration clears them once this is deployed and verified. + /// + [Migration(133)] + public class M0133_AddMemberDepartmentAddresses : Migration + { + public override void Up() + { + if (!Schema.Table("DepartmentMemberSensitiveData").Column("HomeAddress1").Exists()) + Alter.Table("DepartmentMemberSensitiveData") + .AddColumn("HomeAddress1").AsString(int.MaxValue).Nullable() + .AddColumn("HomeCity").AsString(int.MaxValue).Nullable() + .AddColumn("HomeState").AsString(int.MaxValue).Nullable() + .AddColumn("HomePostalCode").AsString(int.MaxValue).Nullable() + .AddColumn("HomeCountry").AsString(int.MaxValue).Nullable() + .AddColumn("MailingAddress1").AsString(int.MaxValue).Nullable() + .AddColumn("MailingCity").AsString(int.MaxValue).Nullable() + .AddColumn("MailingState").AsString(int.MaxValue).Nullable() + .AddColumn("MailingPostalCode").AsString(int.MaxValue).Nullable() + .AddColumn("MailingCountry").AsString(int.MaxValue).Nullable(); + + // Backfill from the member's existing profile addresses, once per department they belong + // to. Re-runnable: only fills rows whose home address is still empty, so a + // department-specific address someone has already entered is never overwritten. + Execute.Sql(@" +UPDATE s +SET s.[HomeAddress1] = ha.[Address1], s.[HomeCity] = ha.[City], s.[HomeState] = ha.[State], + s.[HomePostalCode] = ha.[PostalCode], s.[HomeCountry] = ha.[Country] +FROM [DepartmentMemberSensitiveData] s +INNER JOIN [UserProfiles] up ON up.[UserId] = s.[UserId] +INNER JOIN [Addresses] ha ON ha.[AddressId] = up.[HomeAddressId] +WHERE s.[HomeAddress1] IS NULL AND s.[IsProtected] = 0;"); + + Execute.Sql(@" +UPDATE s +SET s.[MailingAddress1] = ma.[Address1], s.[MailingCity] = ma.[City], s.[MailingState] = ma.[State], + s.[MailingPostalCode] = ma.[PostalCode], s.[MailingCountry] = ma.[Country] +FROM [DepartmentMemberSensitiveData] s +INNER JOIN [UserProfiles] up ON up.[UserId] = s.[UserId] +INNER JOIN [Addresses] ma ON ma.[AddressId] = up.[MailingAddressId] +WHERE s.[MailingAddress1] IS NULL AND s.[IsProtected] = 0;"); + } + + public override void Down() + { + // Only safe while every department is Disabled: for a protected department these columns + // hold rgdp ciphertext that exists nowhere else. + if (Schema.Table("DepartmentMemberSensitiveData").Column("HomeAddress1").Exists()) + Delete.Column("HomeAddress1").Column("HomeCity").Column("HomeState") + .Column("HomePostalCode").Column("HomeCountry") + .Column("MailingAddress1").Column("MailingCity").Column("MailingState") + .Column("MailingPostalCode").Column("MailingCountry") + .FromTable("DepartmentMemberSensitiveData"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0134_CompleteMemberProfileRelocation.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0134_CompleteMemberProfileRelocation.cs new file mode 100644 index 000000000..62c56b7a8 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0134_CompleteMemberProfileRelocation.cs @@ -0,0 +1,96 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Completes the EXPAND half of moving member identification numbers and addresses off the + /// global UserProfiles row onto the department-scoped DepartmentMemberSensitiveData row + /// (ADP plan section 5.1), and adds the marker that makes the move verifiable. + /// + /// M0132 inserted rows only for members who had an identification number, and M0133 backfilled + /// addresses only into rows that already existed — so a member with an address but no + /// identification number was moved by neither. This migration closes that gap and then stamps + /// every relocated row, so "has this member's legacy data been moved?" is a column lookup rather + /// than an inference from which target fields happen to be empty. Inferring it from emptiness is + /// wrong: a member who deliberately CLEARS their department identification number would look + /// un-relocated forever and have the legacy value pushed back on top of them. + /// + /// Rows already carrying ciphertext (IsProtected = 1) are deliberately left alone here. Writing + /// plaintext into an enrolled department's row would poison it, so those departments are + /// relocated by MemberProfileRelocationService instead, which goes through the ADP write path + /// and encrypts the moved value as it lands. They stay unstamped until it does. + /// + /// The legacy source columns are NOT cleared. That is the contract migration's job, once this is + /// deployed, the relocation backlog reads zero, and a rollback can no longer lose data. + /// + [Migration(134)] + public class M0134_CompleteMemberProfileRelocation : Migration + { + public override void Up() + { + if (!Schema.Table("DepartmentMemberSensitiveData").Column("LegacyProfileRelocatedOn").Exists()) + Alter.Table("DepartmentMemberSensitiveData") + .AddColumn("LegacyProfileRelocatedOn").AsDateTime2().Nullable(); + + // 1) Rows for members whose legacy profile holds an address but no identification number + // (M0132 skipped them) or who joined a department after M0132 ran. + Execute.Sql(@" +INSERT INTO [DepartmentMemberSensitiveData] ([DepartmentId], [UserId], [ProtectionId], [IsProtected], [CreatedOn]) +SELECT dm.[DepartmentId], dm.[UserId], LOWER(REPLACE(CONVERT(NVARCHAR(64), NEWID()), '-', '')), 0, GETUTCDATE() +FROM [DepartmentMembers] dm +INNER JOIN [UserProfiles] up ON up.[UserId] = dm.[UserId] +WHERE dm.[IsDeleted] = 0 + AND (up.[HomeAddressId] IS NOT NULL OR up.[MailingAddressId] IS NOT NULL + OR (up.[IdentificationNumber] IS NOT NULL AND LTRIM(RTRIM(up.[IdentificationNumber])) <> '')) + AND NOT EXISTS ( + SELECT 1 FROM [DepartmentMemberSensitiveData] s + WHERE s.[DepartmentId] = dm.[DepartmentId] AND s.[UserId] = dm.[UserId]);"); + + // 2) Fill the three families into any still-empty, still-plaintext target. Re-runnable: + // a department-specific value someone has already entered is never overwritten. + Execute.Sql(@" +UPDATE s +SET s.[IdentificationNumber] = up.[IdentificationNumber] +FROM [DepartmentMemberSensitiveData] s +INNER JOIN [UserProfiles] up ON up.[UserId] = s.[UserId] +WHERE s.[IdentificationNumber] IS NULL AND s.[IsProtected] = 0 AND s.[LegacyProfileRelocatedOn] IS NULL + AND up.[IdentificationNumber] IS NOT NULL AND LTRIM(RTRIM(up.[IdentificationNumber])) <> '';"); + + Execute.Sql(@" +UPDATE s +SET s.[HomeAddress1] = ha.[Address1], s.[HomeCity] = ha.[City], s.[HomeState] = ha.[State], + s.[HomePostalCode] = ha.[PostalCode], s.[HomeCountry] = ha.[Country] +FROM [DepartmentMemberSensitiveData] s +INNER JOIN [UserProfiles] up ON up.[UserId] = s.[UserId] +INNER JOIN [Addresses] ha ON ha.[AddressId] = up.[HomeAddressId] +WHERE s.[HomeAddress1] IS NULL AND s.[IsProtected] = 0 AND s.[LegacyProfileRelocatedOn] IS NULL;"); + + Execute.Sql(@" +UPDATE s +SET s.[MailingAddress1] = ma.[Address1], s.[MailingCity] = ma.[City], s.[MailingState] = ma.[State], + s.[MailingPostalCode] = ma.[PostalCode], s.[MailingCountry] = ma.[Country] +FROM [DepartmentMemberSensitiveData] s +INNER JOIN [UserProfiles] up ON up.[UserId] = s.[UserId] +INNER JOIN [Addresses] ma ON ma.[AddressId] = up.[MailingAddressId] +WHERE s.[MailingAddress1] IS NULL AND s.[IsProtected] = 0 AND s.[LegacyProfileRelocatedOn] IS NULL;"); + + // 3) Stamp what moved. Every plaintext row is now relocated — including rows whose member + // simply had nothing to move, which are relocated by definition and must not be + // revisited by the worker on every pass. + Execute.Sql(@" +UPDATE [DepartmentMemberSensitiveData] +SET [LegacyProfileRelocatedOn] = GETUTCDATE() +WHERE [LegacyProfileRelocatedOn] IS NULL AND [IsProtected] = 0;"); + } + + public override void Down() + { + // The relocated copies stay: they may have been edited per department or encrypted since, + // and the source was never cleared, so there is nothing to restore. Dropping only the + // marker returns every row to the backlog, which is safe — relocation is idempotent and + // refuses to overwrite a populated target. + if (Schema.Table("DepartmentMemberSensitiveData").Column("LegacyProfileRelocatedOn").Exists()) + Delete.Column("LegacyProfileRelocatedOn").FromTable("DepartmentMemberSensitiveData"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0135_PrepareCertificationsForProtection.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0135_PrepareCertificationsForProtection.cs new file mode 100644 index 000000000..2ff506f92 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0135_PrepareCertificationsForProtection.cs @@ -0,0 +1,43 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Brings PersonnelCertifications into the ADP schema (plan section 5.1, Personnel family: + /// "license/certification numbers and documents"). The table predates this repository's + /// migrations, so it was never covered by M0127's capacity pass or M0128's row markers. + /// + /// Two changes: every cataloged string column goes to NVARCHAR(MAX), because an AES-GCM envelope + /// needs roughly 1.4 x plaintext + 70 characters and a bounded column cannot hold one for + /// near-cap plaintext; and IsProtected marks a row whose values carry envelopes. Widening + /// NVARCHAR(n) to MAX is metadata-only on SQL Server. Nullability is preserved exactly — Name is + /// required on the entity, the rest are not — so this changes capacity and nothing else. + /// + /// Additive and inert while no department is enrolled: the columns simply hold what they held. + /// + [Migration(135)] + public class M0135_PrepareCertificationsForProtection : Migration + { + public override void Up() + { + Alter.Table("PersonnelCertifications").AlterColumn("Name").AsString(int.MaxValue).NotNullable(); + Alter.Table("PersonnelCertifications").AlterColumn("Number").AsString(int.MaxValue).Nullable(); + Alter.Table("PersonnelCertifications").AlterColumn("Type").AsString(int.MaxValue).Nullable(); + Alter.Table("PersonnelCertifications").AlterColumn("Area").AsString(int.MaxValue).Nullable(); + Alter.Table("PersonnelCertifications").AlterColumn("IssuedBy").AsString(int.MaxValue).Nullable(); + Alter.Table("PersonnelCertifications").AlterColumn("Filename").AsString(int.MaxValue).Nullable(); + + if (!Schema.Table("PersonnelCertifications").Column("IsProtected").Exists()) + Alter.Table("PersonnelCertifications") + .AddColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false); + } + + public override void Down() + { + // The marker only: narrowing the widened columns would truncate envelopes, and for a + // protected department those columns are the only copy of the data. + if (Schema.Table("PersonnelCertifications").Column("IsProtected").Exists()) + Delete.Column("IsProtected").FromTable("PersonnelCertifications"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0131_AddDepartmentMemberEmergencyContactsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0131_AddDepartmentMemberEmergencyContactsPg.cs new file mode 100644 index 000000000..279fb5ddf --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0131_AddDepartmentMemberEmergencyContactsPg.cs @@ -0,0 +1,67 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Department-scoped emergency contacts for a member (ADP plan section 5.1). A member can have + /// MORE THAN ONE, and the set is per department: userprofiles is global to the user and shared + /// across every department they belong to, so it can neither be encrypted with one department's + /// key nor hold values that legitimately differ between departments. + /// + /// Also drops the single-value emergencycontactname/phone columns M0124 speculatively added to + /// departmentmembersensitivedata — never populated, never surfaced, superseded by this table. + /// + [Migration(131)] + public class M0131_AddDepartmentMemberEmergencyContactsPg : Migration + { + public override void Up() + { + if (!Schema.Table("departmentmemberemergencycontacts").Exists()) + { + Create.Table("departmentmemberemergencycontacts") + .WithColumn("departmentmemberemergencycontactid").AsInt32().NotNullable().PrimaryKey().Identity() + .WithColumn("departmentid").AsInt32().NotNullable() + .WithColumn("userid").AsString(128).NotNullable() + .WithColumn("name").AsCustom("citext").Nullable() + .WithColumn("relationship").AsCustom("citext").Nullable() + .WithColumn("phonenumber").AsCustom("citext").Nullable() + .WithColumn("alternatephonenumber").AsCustom("citext").Nullable() + .WithColumn("email").AsCustom("citext").Nullable() + .WithColumn("notes").AsCustom("citext").Nullable() + .WithColumn("isprimary").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("sortorder").AsInt32().NotNullable().WithDefaultValue(0) + .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false) + // ADP row marker: set once the row's cataloged columns carry rgdp envelopes. + .WithColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false) + .WithColumn("createdon").AsDateTime().NotNullable() + .WithColumn("createdbyuserid").AsString(128).Nullable() + .WithColumn("updatedon").AsDateTime().Nullable() + .WithColumn("updatedbyuserid").AsString(128).Nullable(); + + Create.Index("ix_departmentmemberemergencycontacts_department_user") + .OnTable("departmentmemberemergencycontacts") + .OnColumn("departmentid").Ascending() + .OnColumn("userid").Ascending(); + } + + if (Schema.Table("departmentmembersensitivedata").Column("emergencycontactname").Exists()) + Delete.Column("emergencycontactname").FromTable("departmentmembersensitivedata"); + + if (Schema.Table("departmentmembersensitivedata").Column("emergencycontactphone").Exists()) + Delete.Column("emergencycontactphone").FromTable("departmentmembersensitivedata"); + } + + public override void Down() + { + // Only safe while every department is Disabled: dropping this table on a protected + // department destroys emergency-contact ciphertext that cannot be recovered. + if (Schema.Table("departmentmemberemergencycontacts").Exists()) + Delete.Table("departmentmemberemergencycontacts"); + + if (!Schema.Table("departmentmembersensitivedata").Column("emergencycontactname").Exists()) + Alter.Table("departmentmembersensitivedata") + .AddColumn("emergencycontactname").AsCustom("citext").Nullable() + .AddColumn("emergencycontactphone").AsCustom("citext").Nullable(); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0132_BackfillMemberIdentificationNumbersPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0132_BackfillMemberIdentificationNumbersPg.cs new file mode 100644 index 000000000..da5de0cc6 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0132_BackfillMemberIdentificationNumbersPg.cs @@ -0,0 +1,34 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Moves member identification numbers from the GLOBAL userprofiles row to the department-scoped + /// departmentmembersensitivedata row (ADP plan section 5.1). See the SQL Server counterpart for + /// the reasoning; this is the EXPAND phase and leaves userprofiles.identificationnumber in place. + /// + [Migration(132)] + public class M0132_BackfillMemberIdentificationNumbersPg : Migration + { + public override void Up() + { + Execute.Sql(@" +INSERT INTO departmentmembersensitivedata (departmentid, userid, protectionid, identificationnumber, isprotected, createdon) +SELECT dm.departmentid, dm.userid, md5(random()::text || clock_timestamp()::text), up.identificationnumber, false, (NOW() AT TIME ZONE 'utc') +FROM departmentmembers dm +INNER JOIN userprofiles up ON up.userid = dm.userid +WHERE dm.isdeleted = false + AND up.identificationnumber IS NOT NULL + AND btrim(up.identificationnumber) <> '' + AND NOT EXISTS ( + SELECT 1 FROM departmentmembersensitivedata s + WHERE s.departmentid = dm.departmentid AND s.userid = dm.userid);"); + } + + public override void Down() + { + // Nothing to restore: the source column was never cleared, and the copies may since have + // diverged per department or been encrypted. + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0133_AddMemberDepartmentAddressesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0133_AddMemberDepartmentAddressesPg.cs new file mode 100644 index 000000000..c99ed963f --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0133_AddMemberDepartmentAddressesPg.cs @@ -0,0 +1,58 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Department-scoped member addresses (ADP plan section 5.1). See the SQL Server counterpart: + /// stored as columns rather than a link to the shared addresses table, because an addresses row + /// has no owner and encrypting it with one department's key would break every other reader. + /// EXPAND phase — the legacy profile address links are left intact and backfilled from. + /// + [Migration(133)] + public class M0133_AddMemberDepartmentAddressesPg : Migration + { + public override void Up() + { + if (!Schema.Table("departmentmembersensitivedata").Column("homeaddress1").Exists()) + Alter.Table("departmentmembersensitivedata") + .AddColumn("homeaddress1").AsCustom("citext").Nullable() + .AddColumn("homecity").AsCustom("citext").Nullable() + .AddColumn("homestate").AsCustom("citext").Nullable() + .AddColumn("homepostalcode").AsCustom("citext").Nullable() + .AddColumn("homecountry").AsCustom("citext").Nullable() + .AddColumn("mailingaddress1").AsCustom("citext").Nullable() + .AddColumn("mailingcity").AsCustom("citext").Nullable() + .AddColumn("mailingstate").AsCustom("citext").Nullable() + .AddColumn("mailingpostalcode").AsCustom("citext").Nullable() + .AddColumn("mailingcountry").AsCustom("citext").Nullable(); + + Execute.Sql(@" +UPDATE departmentmembersensitivedata s +SET homeaddress1 = ha.address1, homecity = ha.city, homestate = ha.state, + homepostalcode = ha.postalcode, homecountry = ha.country +FROM userprofiles up +INNER JOIN addresses ha ON ha.addressid = up.homeaddressid +WHERE up.userid = s.userid AND s.homeaddress1 IS NULL AND s.isprotected = false;"); + + Execute.Sql(@" +UPDATE departmentmembersensitivedata s +SET mailingaddress1 = ma.address1, mailingcity = ma.city, mailingstate = ma.state, + mailingpostalcode = ma.postalcode, mailingcountry = ma.country +FROM userprofiles up +INNER JOIN addresses ma ON ma.addressid = up.mailingaddressid +WHERE up.userid = s.userid AND s.mailingaddress1 IS NULL AND s.isprotected = false;"); + } + + public override void Down() + { + // Only safe while every department is Disabled: for a protected department these columns + // hold rgdp ciphertext that exists nowhere else. + if (Schema.Table("departmentmembersensitivedata").Column("homeaddress1").Exists()) + Delete.Column("homeaddress1").Column("homecity").Column("homestate") + .Column("homepostalcode").Column("homecountry") + .Column("mailingaddress1").Column("mailingcity").Column("mailingstate") + .Column("mailingpostalcode").Column("mailingcountry") + .FromTable("departmentmembersensitivedata"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0134_CompleteMemberProfileRelocationPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0134_CompleteMemberProfileRelocationPg.cs new file mode 100644 index 000000000..b990fe416 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0134_CompleteMemberProfileRelocationPg.cs @@ -0,0 +1,74 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Postgres twin of M0134_CompleteMemberProfileRelocation. See that migration for why the move + /// needs a marker column rather than inferring "already relocated" from an empty target, and why + /// rows that already carry ciphertext are left for MemberProfileRelocationService. + /// + [Migration(134)] + public class M0134_CompleteMemberProfileRelocationPg : Migration + { + public override void Up() + { + if (!Schema.Table("departmentmembersensitivedata").Column("legacyprofilerelocatedon").Exists()) + Alter.Table("departmentmembersensitivedata") + .AddColumn("legacyprofilerelocatedon").AsDateTime2().Nullable(); + + // 1) Rows for members whose legacy profile holds an address but no identification number + // (M0132 skipped them) or who joined a department after M0132 ran. md5(random()) keeps + // ProtectionId's 32-hex shape without depending on the pgcrypto extension. + Execute.Sql(@" +INSERT INTO departmentmembersensitivedata (departmentid, userid, protectionid, isprotected, createdon) +SELECT dm.departmentid, dm.userid, md5(random()::text || clock_timestamp()::text), false, (now() at time zone 'utc') +FROM departmentmembers dm +INNER JOIN userprofiles up ON up.userid = dm.userid +WHERE dm.isdeleted = false + AND (up.homeaddressid IS NOT NULL OR up.mailingaddressid IS NOT NULL + OR (up.identificationnumber IS NOT NULL AND btrim(up.identificationnumber) <> '')) + AND NOT EXISTS ( + SELECT 1 FROM departmentmembersensitivedata s + WHERE s.departmentid = dm.departmentid AND s.userid = dm.userid);"); + + // 2) Fill the three families into any still-empty, still-plaintext target. + Execute.Sql(@" +UPDATE departmentmembersensitivedata s +SET identificationnumber = up.identificationnumber +FROM userprofiles up +WHERE up.userid = s.userid + AND s.identificationnumber IS NULL AND s.isprotected = false AND s.legacyprofilerelocatedon IS NULL + AND up.identificationnumber IS NOT NULL AND btrim(up.identificationnumber) <> '';"); + + Execute.Sql(@" +UPDATE departmentmembersensitivedata s +SET homeaddress1 = ha.address1, homecity = ha.city, homestate = ha.state, + homepostalcode = ha.postalcode, homecountry = ha.country +FROM userprofiles up +INNER JOIN addresses ha ON ha.addressid = up.homeaddressid +WHERE up.userid = s.userid + AND s.homeaddress1 IS NULL AND s.isprotected = false AND s.legacyprofilerelocatedon IS NULL;"); + + Execute.Sql(@" +UPDATE departmentmembersensitivedata s +SET mailingaddress1 = ma.address1, mailingcity = ma.city, mailingstate = ma.state, + mailingpostalcode = ma.postalcode, mailingcountry = ma.country +FROM userprofiles up +INNER JOIN addresses ma ON ma.addressid = up.mailingaddressid +WHERE up.userid = s.userid + AND s.mailingaddress1 IS NULL AND s.isprotected = false AND s.legacyprofilerelocatedon IS NULL;"); + + // 3) Stamp what moved, including rows whose member had nothing to move. + Execute.Sql(@" +UPDATE departmentmembersensitivedata +SET legacyprofilerelocatedon = (now() at time zone 'utc') +WHERE legacyprofilerelocatedon IS NULL AND isprotected = false;"); + } + + public override void Down() + { + if (Schema.Table("departmentmembersensitivedata").Column("legacyprofilerelocatedon").Exists()) + Delete.Column("legacyprofilerelocatedon").FromTable("departmentmembersensitivedata"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0135_PrepareCertificationsForProtectionPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0135_PrepareCertificationsForProtectionPg.cs new file mode 100644 index 000000000..786b2fb05 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0135_PrepareCertificationsForProtectionPg.cs @@ -0,0 +1,26 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Postgres twin of M0135_PrepareCertificationsForProtection. The capacity half is a no-op here: + /// PostgreSQL stores these columns as unbounded citext, so an rgdp envelope always fits. Only the + /// row marker is added. + /// + [Migration(135)] + public class M0135_PrepareCertificationsForProtectionPg : Migration + { + public override void Up() + { + if (!Schema.Table("personnelcertifications").Column("isprotected").Exists()) + Alter.Table("personnelcertifications") + .AddColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false); + } + + public override void Down() + { + if (Schema.Table("personnelcertifications").Column("isprotected").Exists()) + Delete.Column("isprotected").FromTable("personnelcertifications"); + } + } +} diff --git a/Providers/Resgrid.Providers.Number/NumbersProviderModule.cs b/Providers/Resgrid.Providers.Number/NumbersProviderModule.cs index 807c88de5..f0b191526 100644 --- a/Providers/Resgrid.Providers.Number/NumbersProviderModule.cs +++ b/Providers/Resgrid.Providers.Number/NumbersProviderModule.cs @@ -11,6 +11,11 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + // ADP outbound net (plan 7.5). SMS is scrubbed in place; voice is rebuilt through the + // protected projection, because a prompt read aloud as "REDACTED" helps nobody. + builder.RegisterDecorator(); + builder.RegisterDecorator(); + builder.RegisterType().As().SingleInstance(); } } diff --git a/Providers/Resgrid.Providers.Number/ProtectedOutboundVoiceProviderDecorator.cs b/Providers/Resgrid.Providers.Number/ProtectedOutboundVoiceProviderDecorator.cs new file mode 100644 index 000000000..3e8af0624 --- /dev/null +++ b/Providers/Resgrid.Providers.Number/ProtectedOutboundVoiceProviderDecorator.cs @@ -0,0 +1,89 @@ +using System; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; + +namespace Resgrid.Providers.NumberProvider +{ + /// + /// Outbound-boundary net for voice dispatch (ADP plan section 7.5, queue side). The provider is + /// handed the Call entity and builds the spoken prompt from it, so an unsanitized call would be + /// read aloud as base64 — a phone call that is both a disclosure and useless to the responder. + /// + /// Unlike the text channels this does not scrub field by field: a prompt full of "REDACTED" is + /// no better than one full of ciphertext. It re-runs the projection the caller should have run, + /// which yields the properly worded generic dispatch, and logs that the caller skipped it. + /// + public class ProtectedOutboundVoiceProviderDecorator : IOutboundVoiceProvider + { + private readonly IOutboundVoiceProvider _inner; + private readonly IProtectedProjectionService _protectedProjectionService; + + public ProtectedOutboundVoiceProviderDecorator(IOutboundVoiceProvider inner, + IProtectedProjectionService protectedProjectionService) + { + _inner = inner; + _protectedProjectionService = protectedProjectionService; + } + + public async Task CommunicateCallAsync(string phoneNumber, UserProfile profile, Call call) + { + try + { + if (call != null && HasEnvelopedField(call)) + { + Logging.LogError($"ADP outbound net caught an unsanitized voice dispatch for department {call.DepartmentId} " + + $"(call {call.CallId}); rebuilding it through the protected projection. The caller is missing its safe projection."); + + call = await _protectedProjectionService.BuildNotificationSafeCallAsync(call.DepartmentId, call, + ProtectedDataEgressChannel.Voice); + } + } + catch (Exception ex) + { + // A failure here must not silence a dispatch call, but it must not let ciphertext be + // spoken either — fall back to the value-free shell. + Logging.LogException(ex, "ProtectedOutboundVoiceProviderDecorator failed while sanitizing a voice dispatch"); + call = BuildMinimalShell(call); + } + + return await _inner.CommunicateCallAsync(phoneNumber, profile, call); + } + + public Task SendVoiceVerificationCallAsync(string phoneNumber, string userId, int contactType) + => _inner.SendVoiceVerificationCallAsync(phoneNumber, userId, contactType); + + public Task SendCommunicationTestCallAsync(string phoneNumber, string responseToken) + => _inner.SendCommunicationTestCallAsync(phoneNumber, responseToken); + + private static bool HasEnvelopedField(Call call) + { + return ProtectedDataEnvelope.HasEnvelopePrefix(call.Name) || + ProtectedDataEnvelope.HasEnvelopePrefix(call.NatureOfCall) || + ProtectedDataEnvelope.HasEnvelopePrefix(call.Address) || + ProtectedDataEnvelope.HasEnvelopePrefix(call.Type) || + ProtectedDataEnvelope.HasEnvelopePrefix(call.Notes); + } + + private static Call BuildMinimalShell(Call call) + { + if (call == null) + return null; + + return new Call + { + CallId = call.CallId, + DepartmentId = call.DepartmentId, + Department = call.Department, + Number = call.Number, + Priority = call.Priority, + CallPriority = call.CallPriority, + State = call.State, + LoggedOn = call.LoggedOn, + Name = string.IsNullOrWhiteSpace(call.Number) ? "Protected dispatch" : call.Number + }; + } + } +} diff --git a/Providers/Resgrid.Providers.Number/ProtectedTextMessageProviderDecorator.cs b/Providers/Resgrid.Providers.Number/ProtectedTextMessageProviderDecorator.cs new file mode 100644 index 000000000..0b027c98d --- /dev/null +++ b/Providers/Resgrid.Providers.Number/ProtectedTextMessageProviderDecorator.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; + +namespace Resgrid.Providers.NumberProvider +{ + /// + /// Outbound-boundary net for SMS (ADP plan section 7.5, queue side). Every text the platform + /// sends goes through , so this covers dispatch, notifications, + /// chatbot replies and worker output in one place. + /// + /// An SMS is the worst channel to leak into: it is delivered to a carrier, stored on a handset, + /// and cannot be recalled. It is also the worst channel to block — a scrubbed dispatch still + /// tells a responder to open the app, an unsent one tells them nothing. So this scrubs and logs + /// rather than refusing. + /// + public class ProtectedTextMessageProviderDecorator : ITextMessageProvider + { + private readonly ITextMessageProvider _inner; + + public ProtectedTextMessageProviderDecorator(ITextMessageProvider inner) + { + _inner = inner; + } + + public async Task SendTextMessage(string number, string message, string departmentNumber, + MobileCarriers carrier, int departmentId, bool forceGateway = false, bool isCall = false, + int maxLengthOverride = 0) + { + try + { + if (ProtectedOutboundGuard.MightContainEnvelope(message)) + { + message = ProtectedOutboundGuard.Scrub(message, out var scrubbed); + + if (scrubbed > 0) + { + // No number, no content — the department and channel are what identify the + // broken path. + Logging.LogError($"ADP outbound net scrubbed {scrubbed} enveloped value(s) from an SMS for department {departmentId} " + + $"({(isCall ? "dispatch" : "notification")}). A notification path is missing its protected projection."); + } + } + } + catch (Exception ex) + { + Logging.LogException(ex, $"ProtectedTextMessageProviderDecorator failed while sanitizing an outbound SMS for department {departmentId}"); + } + + return await _inner.SendTextMessage(number, message, departmentNumber, carrier, departmentId, + forceGateway, isCall, maxLengthOverride); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberEmergencyContactRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberEmergencyContactRepository.cs new file mode 100644 index 000000000..0f2ca4baf --- /dev/null +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberEmergencyContactRepository.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +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 DepartmentMemberEmergencyContactRepository : RepositoryBase, IDepartmentMemberEmergencyContactRepository + { + private readonly IConnectionProvider _connectionProvider; + private readonly IUnitOfWork _unitOfWork; + private readonly string _table; + private readonly bool _isPostgres; + + public DepartmentMemberEmergencyContactRepository(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}.departmentmemberemergencycontacts" + : $"{sqlConfiguration.SchemaName}.[DepartmentMemberEmergencyContacts]"; + } + + public Task> GetAllByDepartmentAndUserAsync(int departmentId, string userId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId AND userid = @UserId AND isdeleted = false ORDER BY isprimary DESC, sortorder ASC, departmentmemberemergencycontactid ASC" + : $"SELECT * FROM {_table} WHERE [DepartmentId] = @DepartmentId AND [UserId] = @UserId AND [IsDeleted] = 0 ORDER BY [IsPrimary] DESC, [SortOrder] ASC, [DepartmentMemberEmergencyContactId] ASC"; + + return WithConnectionAsync(connection => connection.QueryAsync( + sql, new { DepartmentId = departmentId, UserId = userId }, _unitOfWork?.Transaction)); + } + + public Task DeleteAllByDepartmentAndUserAsync(int departmentId, string userId) + { + var sql = _isPostgres + ? $"DELETE FROM {_table} WHERE departmentid = @DepartmentId AND userid = @UserId" + : $"DELETE FROM {_table} WHERE [DepartmentId] = @DepartmentId AND [UserId] = @UserId"; + + return WithConnectionAsync(connection => connection.ExecuteAsync( + 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/DepartmentMemberSensitiveDataRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberSensitiveDataRepository.cs index 7a940cd2c..bd0c42004 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberSensitiveDataRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberSensitiveDataRepository.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Data.Common; using System.Threading.Tasks; using Dapper; @@ -39,6 +40,42 @@ public Task GetByDepartmentAndUserAsync(int depar sql, new { DepartmentId = departmentId, UserId = userId }, _unitOfWork?.Transaction)); } + public Task> GetAllByDepartmentIdAsync(int departmentId) + { + var sql = _isPostgres + ? $"SELECT * FROM {_table} WHERE departmentid = @DepartmentId" + : $"SELECT * FROM {_table} WHERE [DepartmentId] = @DepartmentId"; + + return WithConnectionAsync(connection => connection.QueryAsync( + sql, new { DepartmentId = departmentId }, _unitOfWork?.Transaction)); + } + + public Task> GetDepartmentIdsWithOutstandingLegacyProfileDataAsync() + { + // "Outstanding" is the ABSENCE of the relocation marker, not an empty target column: a + // member who cleared their department identification number has an empty target and must + // not be swept forever. A member with no row at all is outstanding by definition. + var sql = _isPostgres + ? $@"SELECT DISTINCT dm.departmentid +FROM departmentmembers dm +INNER JOIN userprofiles up ON up.userid = dm.userid +LEFT JOIN {_table} s ON s.departmentid = dm.departmentid AND s.userid = dm.userid +WHERE dm.isdeleted = false + AND s.legacyprofilerelocatedon IS NULL + AND (up.homeaddressid IS NOT NULL OR up.mailingaddressid IS NOT NULL + OR (up.identificationnumber IS NOT NULL AND btrim(up.identificationnumber) <> ''))" + : $@"SELECT DISTINCT dm.[DepartmentId] +FROM [DepartmentMembers] dm +INNER JOIN [UserProfiles] up ON up.[UserId] = dm.[UserId] +LEFT JOIN {_table} s ON s.[DepartmentId] = dm.[DepartmentId] AND s.[UserId] = dm.[UserId] +WHERE dm.[IsDeleted] = 0 + AND s.[LegacyProfileRelocatedOn] IS NULL + AND (up.[HomeAddressId] IS NOT NULL OR up.[MailingAddressId] IS NOT NULL + OR (up.[IdentificationNumber] IS NOT NULL AND LTRIM(RTRIM(up.[IdentificationNumber])) <> ''))"; + + return WithConnectionAsync(connection => connection.QueryAsync(sql, null, _unitOfWork?.Transaction)); + } + private async Task WithConnectionAsync(Func> operation) { if (_unitOfWork?.Connection != null) diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs index abe3e724d..0a0ec2784 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs @@ -268,6 +268,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Tests/Resgrid.Tests/Bootstrapper.cs b/Tests/Resgrid.Tests/Bootstrapper.cs index e06508711..ab484cb9f 100644 --- a/Tests/Resgrid.Tests/Bootstrapper.cs +++ b/Tests/Resgrid.Tests/Bootstrapper.cs @@ -102,6 +102,16 @@ public static void Initialize() .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); protectedWriteStub.Setup(x => x.PrepareContactNoteWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); + protectedWriteStub.Setup(x => x.PrepareUnitStateWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) + .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); + protectedWriteStub.Setup(x => x.PrepareUdfFieldValueWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) + .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); + protectedWriteStub.Setup(x => x.PrepareLogWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) + .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); + protectedWriteStub.Setup(x => x.PrepareMemberSensitiveDataWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) + .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); + protectedWriteStub.Setup(x => x.PrepareMemberEmergencyContactWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) + .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); builder.RegisterInstance(protectedWriteStub.Object) .As(); diff --git a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs index 69bd27ccf..6a31bc1f3 100644 --- a/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs +++ b/Tests/Resgrid.Tests/Localization/TranslationCompletenessTests.cs @@ -30,6 +30,9 @@ public class TranslationCompletenessTests { "Areas/User/CommunicationTest/CommunicationTest", "Areas/User/SystemMessages/SystemMessages", + // ADP screens (enrollment wizard, protection status, emergency contacts) shipped fully + // translated; guarding them stops English placeholders creeping back in. + "Areas/User/DataProtection/DataProtection", }; private static string LocalizationRoot() @@ -62,6 +65,14 @@ private static Dictionary Load(string path) /// private static readonly HashSet KnownIdentical = new HashSet(StringComparer.Ordinal) { + // ADP screens: these five genuinely are the same word in the target language, checked + // by reading the pair side by side rather than inferred from length or casing. + "DataProtection|de|ContactNameLabel", // "Name" is the German word too. + "DataProtection|fr|NotesLabel", // "Notes" is French as well. + "DataProtection|it|BreadcrumbHome", // Italian UIs use the English "Home". + "DataProtection|it|EmailLabel", // "Email" is standard Italian usage. + "DataProtection|el|EmailLabel", // Greek UIs use the Latin-script "Email". + // Brand and protocol names carry across every language. "CommunicationTest|de|Push", "CommunicationTest|de|SMS", "CommunicationTest|es|Push", "CommunicationTest|es|SMS", diff --git a/Tests/Resgrid.Tests/Models/CallTests.cs b/Tests/Resgrid.Tests/Models/CallTests.cs index fdecfd0dd..46e5e149c 100644 --- a/Tests/Resgrid.Tests/Models/CallTests.cs +++ b/Tests/Resgrid.Tests/Models/CallTests.cs @@ -84,5 +84,30 @@ public void GetDisplayName_IsEmpty_WhenTheCallHasNeither() { new Call().GetDisplayName().Should().BeEmpty(); } + + [Test] + public void GetDisplayName_FallsBackToTheNumber_WhenTheNameIsStillEnveloped() + { + // Every caller of this helper names a chat channel, and that name is persisted and shown + // to the whole department. Calls.Name is cataloged; Calls.Number deliberately is not. + var call = new Call { Number = "26-45", Name = "rgdp:1:2:c3RydWN0dXJlLWZpcmU=" }; + + call.GetDisplayName().Should().Be("26-45"); + } + + [Test] + public void GetDisplayName_FallsBackToTheNumber_WhenTheNameIsRedacted() + { + // A durable label reading "26-45 REDACTED" would be worse than one reading "26-45". + var call = new Call { Number = "26-45", Name = ProtectedDataEnvelope.RedactionValue }; + + call.GetDisplayName().Should().Be("26-45"); + } + + [Test] + public void GetDisplayName_IsEmpty_WhenAProtectedCallHasNoNumber() + { + new Call { Name = "rgdp:1:2:c3RydWN0dXJlLWZpcmU=" }.GetDisplayName().Should().BeEmpty(); + } } } diff --git a/Tests/Resgrid.Tests/Services/AdpCatalogUpgradeScopeTests.cs b/Tests/Resgrid.Tests/Services/AdpCatalogUpgradeScopeTests.cs new file mode 100644 index 000000000..86cfaea50 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/AdpCatalogUpgradeScopeTests.cs @@ -0,0 +1,88 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// A catalog upgrade must sweep ONLY the fields added since the department's pinned version. + /// Re-running the whole catalog would be both wasteful and wrong: existing envelopes are already + /// protected, and (since the catalog version is not an AAD component) they do not need rewriting + /// when the version advances. + /// + [TestFixture] + public class AdpCatalogUpgradeScopeTests + { + private ProtectedFieldCatalog _catalog; + + [SetUp] + public void SetUp() => _catalog = new ProtectedFieldCatalog(); + + [Test] + public void A_current_department_has_nothing_to_sweep() + { + AdpTableBindings.ForVersionRange(_catalog, _catalog.Version, _catalog.Version) + .Should().BeEmpty("a department already at the current catalog is owed no upgrade"); + } + + [Test] + public void A_backwards_or_null_range_sweeps_nothing() + { + AdpTableBindings.ForVersionRange(_catalog, _catalog.Version, _catalog.Version - 1).Should().BeEmpty(); + AdpTableBindings.ForVersionRange(null, 0, _catalog.Version).Should().BeEmpty(); + } + + [Test] + public void A_department_starting_from_nothing_sweeps_the_whole_catalog() + { + var scoped = AdpTableBindings.ForVersionRange(_catalog, 0, _catalog.Version); + + scoped.Select(b => b.TableName) + .Should().BeEquivalentTo(AdpTableBindings.V1.Select(b => b.TableName)); + + scoped.SelectMany(b => b.Columns).Select(c => c.FieldId) + .Should().BeEquivalentTo(AdpTableBindings.V1.SelectMany(b => b.Columns).Select(c => c.FieldId)); + } + + [Test] + public void Scoped_bindings_preserve_addressing_and_the_protected_marker() + { + var scoped = AdpTableBindings.ForVersionRange(_catalog, 0, _catalog.Version); + + foreach (var original in AdpTableBindings.V1) + { + var rebuilt = scoped.Single(b => b.TableName == original.TableName); + + rebuilt.PkColumn.Should().Be(original.PkColumn); + rebuilt.PkIsNumeric.Should().Be(original.PkIsNumeric); + rebuilt.DepartmentColumn.Should().Be(original.DepartmentColumn); + rebuilt.ParentFkColumn.Should().Be(original.ParentFkColumn); + rebuilt.ParentTable.Should().Be(original.ParentTable); + rebuilt.ParentPkColumn.Should().Be(original.ParentPkColumn); + rebuilt.ProtectedMarkerColumn.Should().Be(original.ProtectedMarkerColumn, + "a scoped sweep still has to stamp the row-level protected marker"); + } + } + + [Test] + public void Every_bound_column_is_a_real_catalog_field() + { + // The binding field ids ARE the AAD components; a typo here would encrypt under an id + // the catalog never issued and no read path could ever resolve. + foreach (var column in AdpTableBindings.V1.SelectMany(b => b.Columns)) + { + _catalog.GetById(column.FieldId) + .Should().NotBeNull($"binding field id '{column.FieldId}' must exist in the catalog"); + } + } + + [Test] + public void Catalog_upgrade_is_a_distinct_migration_kind() + { + ((int)DepartmentDataProtectionMigrationKind.CatalogUpgrade).Should().Be(3, + "the persisted value is a stored discriminator and must stay stable"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs index 5ad970095..96dacdf23 100644 --- a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs @@ -55,10 +55,10 @@ public async Task Scan_counts_every_binding_and_derives_the_range_and_nights() "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"); + // 130,000 rows / 100 rps = 1300s + 13×30s overhead = 1690s; ×1.25 = 2112.5s → 36 min P50. + result.EstimatedP50Minutes.Should().Be(36); + result.EstimatedP90Minutes.Should().Be(71); + result.ProjectedNights.Should().Be(1, "the P90 estimate still fits one 480-minute window"); result.BenchmarkRowsPerSecond.Should().Be(100); } diff --git a/Tests/Resgrid.Tests/Services/CallsServiceProtectedWriteTests.cs b/Tests/Resgrid.Tests/Services/CallsServiceProtectedWriteTests.cs index ea5c43af6..6aede65cf 100644 --- a/Tests/Resgrid.Tests/Services/CallsServiceProtectedWriteTests.cs +++ b/Tests/Resgrid.Tests/Services/CallsServiceProtectedWriteTests.cs @@ -161,6 +161,73 @@ public async Task SaveCallAsync_NoSentinel_DoesNotFetchTheStoredRow() _protectedWriteService.Verify(x => x.PrepareCallWriteAsync(10, It.IsAny(), null, null, null, true, It.IsAny()), Times.Once); } + [Test] + public async Task SaveCallNoteAsync_UnresolvableParentCall_FailsClosed() + { + // The department (and so the protection state) is only reachable through the parent call. + // An unresolvable parent must block the write, not silently leave plaintext at rest. + var note = new CallNote { CallNoteId = 8, CallId = 42, Note = "Occupant on O2" }; + + Func act = async () => await _service.SaveCallNoteAsync(note); + + await act.Should().ThrowAsync().WithMessage("*parent call 42 not found*"); + _protectedWriteService.Verify(x => x.PrepareCallNoteWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task SaveCallAttachmentAsync_UnresolvableParentCall_FailsClosed() + { + var attachment = new CallAttachment { CallAttachmentId = 7, CallId = 42, FileName = "scene.jpg" }; + + Func act = async () => await _service.SaveCallAttachmentAsync(attachment); + + await act.Should().ThrowAsync().WithMessage("*parent call 42 not found*"); + _protectedWriteService.Verify(x => x.PrepareCallAttachmentWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task SaveCallNoteAsync_RedactedSentinel_RestoresTheStoredEnvelopeAndResaves() + { + _callsRepo.Setup(x => x.GetByIdAsync(42)).ReturnsAsync(BuildCall()); + _callNotesRepo.Setup(x => x.GetByIdAsync(8)) + .ReturnsAsync(new CallNote { CallNoteId = 8, CallId = 42, Note = "rgdp:1:1:storednote==" }); + + var edited = new CallNote { CallNoteId = 8, CallId = 42, Note = ProtectedDataEnvelope.RedactionValue }; + + var result = await _service.SaveCallNoteAsync(edited); + + result.Note.Should().Be("rgdp:1:1:storednote==", "REDACTED means unchanged — the stored envelope is restored, never persisted literally"); + // Initial save plus the re-save that persists the restore over the placeholder row. + _callNotesRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public async Task SaveCallAttachmentAsync_RedactedSentinel_RestoresTheStoredEnvelope() + { + _callsRepo.Setup(x => x.GetByIdAsync(42)).ReturnsAsync(BuildCall()); + _callAttachmentRepo.Setup(x => x.GetByIdAsync(7)) + .ReturnsAsync(new CallAttachment { CallAttachmentId = 7, CallId = 42, FileName = "rgdp:1:1:storedname==" }); + + var edited = new CallAttachment { CallAttachmentId = 7, CallId = 42, FileName = ProtectedDataEnvelope.RedactionValue }; + + var result = await _service.SaveCallAttachmentAsync(edited); + + result.FileName.Should().Be("rgdp:1:1:storedname=="); + _callAttachmentRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public async Task SaveCallAttachmentAsync_NoSentinel_DoesNotFetchTheStoredRow() + { + _callsRepo.Setup(x => x.GetByIdAsync(42)).ReturnsAsync(BuildCall()); + var attachment = new CallAttachment { CallAttachmentId = 7, CallId = 42, FileName = "scene.jpg" }; + + await _service.SaveCallAttachmentAsync(attachment); + + _callAttachmentRepo.Verify(x => x.GetByIdAsync(It.IsAny()), Times.Never); + _callAttachmentRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + [Test] public async Task SaveCallAsync_BlockedCallWrite_Throws() { diff --git a/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs b/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs index 4c46b5cd1..d50a2447f 100644 --- a/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs +++ b/Tests/Resgrid.Tests/Services/DepartmentDataMigrationEngineTests.cs @@ -57,7 +57,7 @@ public async Task SetUp() _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); + _engine = new DepartmentDataMigrationEngine(_bulk, _migrations, _keyService.Object, _keyProvider, _crypto, new ProtectedFieldCatalog()); // Three call rows: one rich, one sparse, one with empty strings only. _bulk.Seed("Calls", "CallId", @@ -105,7 +105,7 @@ public async Task Enrollment_envelopes_every_populated_text_value_and_persists_t migrationRow.Cursor.Should().Be("3"); migrationRow.RowsProcessed.Should().Be(2); - _crypto.DecryptText(_dek, (string)calls[0]["NatureOfCall"], DeptId, "calls.natureofcall", "1", 1) + _crypto.DecryptText(_dek, (string)calls[0]["NatureOfCall"], DeptId, "calls.natureofcall", "1") .Should().Be("Smoke showing"); } @@ -221,7 +221,7 @@ public async Task Offboarding_restores_plaintext_and_second_decrypt_pass_counts_ 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); + var foreign = _crypto.EncryptText(_dek, 1, "someone else's data", 43, "calls.name", "1"); _bulk.Table("Calls")[0]["Name"] = foreign; var result = await _engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); @@ -250,7 +250,7 @@ public async Task Closed_window_checkpoints_before_touching_any_row() public async Task Missing_kms_fails_the_run_closed() { var engine = new DepartmentDataMigrationEngine(_bulk, _migrations, _keyService.Object, - new NotConfiguredKeyWrappingProvider(), _crypto); + new NotConfiguredKeyWrappingProvider(), _crypto, new ProtectedFieldCatalog()); var result = await engine.RunEncryptionNightAsync(Context(DepartmentDataProtectionMigrationKind.Enrollment), CancellationToken.None); diff --git a/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs b/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs index cf4aa2181..fbcfc68f3 100644 --- a/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs @@ -61,7 +61,7 @@ public void SetUp() _service = new DepartmentDataProtectionService(_policyRepo.Object, _egressRepo.Object, _departmentsService.Object, _featureToggleService.Object, _subscriptionsService.Object, - _cacheProvider.Object); + _cacheProvider.Object, new ProtectedFieldCatalog()); } #region QueueEnrollment gates @@ -411,5 +411,50 @@ public async Task Revoke_offboarding_is_managing_member_only() } #endregion - } + + #region Enforcement during a catalog upgrade + + /// + /// Enrollment's Encrypting state correctly reads as "not enforced" — nothing is encrypted + /// yet. A CATALOG UPGRADE passes through the same state on a department whose corpus is + /// ALREADY fully enveloped, so enforcement must stay on: without it the read pipeline would + /// pass rgdp ciphertext straight through to clients for the whole upgrade window. + /// + [Test] + public async Task Enforcement_stays_on_while_a_catalog_upgrade_sweeps() + { + foreach (var state in new[] { DepartmentDataProtectionState.Encrypting, DepartmentDataProtectionState.Verifying }) + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(new DepartmentDataProtectionPolicy + { + DepartmentDataProtectionPolicyId = 1, + DepartmentId = DeptId, + State = (int)state, + ActiveMigrationKind = (int)DepartmentDataProtectionMigrationKind.CatalogUpgrade + }); + + (await _service.IsProtectionEnforcedAsync(DeptId)).Should().BeTrue($"a catalog upgrade in {state} must keep enforcing"); + } + } + + [Test] + public async Task Enforcement_stays_off_while_enrollment_encrypts() + { + foreach (var state in new[] { DepartmentDataProtectionState.Encrypting, DepartmentDataProtectionState.Verifying }) + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(new DepartmentDataProtectionPolicy + { + DepartmentDataProtectionPolicyId = 1, + DepartmentId = DeptId, + State = (int)state, + ActiveMigrationKind = (int)DepartmentDataProtectionMigrationKind.Enrollment + }); + + (await _service.IsProtectionEnforcedAsync(DeptId)).Should().BeFalse( + "nothing is encrypted during a first enrollment, so there is nothing to enforce"); + } + } + + #endregion +} } diff --git a/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs b/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs index 690df469f..1919da3a8 100644 --- a/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs +++ b/Tests/Resgrid.Tests/Services/DocumentDatabaseProviderSelectionTests.cs @@ -1,3 +1,4 @@ +using System.Threading; using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -269,7 +270,23 @@ private static UnitsService CreateUnitsService(IEventAggregator eventAggregator, new Mock().Object, new Mock().Object, new Mock().Object, - new Mock().Object); + new Mock().Object, + ProtectedWriteStub()); + } + + /// + /// UnitsService's ADP write safety net resolves this lazily. A loose mock would return a null + /// Task from Prepare* (NRE at the await), so the stub answers Allowed() — these tests are + /// about document-provider selection, not protection. + /// + private static Lazy ProtectedWriteStub() + { + var stub = new Mock(); + stub.Setup(x => x.PrepareUnitStateWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + + return new Lazy(() => stub.Object); } private static UsersService CreateUsersService(IEventAggregator eventAggregator, Lazy> mongoRepository, IPersonnelLocationsDocRepository personnelLocationsDocRepository) diff --git a/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs b/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs new file mode 100644 index 000000000..c811160ff --- /dev/null +++ b/Tests/Resgrid.Tests/Services/GdprExportProtectedDataTests.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Identity; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// The GDPR export runs unattended with no protected-data grant (ADP plan 3.4 — background jobs + /// cannot obtain user grants), and the archive it produces is stored in the database for up to + /// seven days behind a one-time download token. Ciphertext must never reach it, and what is held + /// back has to be declared rather than silently dropped from a subject access request. + /// + [TestFixture] + public class GdprExportProtectedDataTests + { + private const int DeptId = 4; + private const string UserId = "user-1"; + + private Mock _repository; + private Mock _userProfileService; + private Mock _memberSensitiveDataService; + private Mock _emergencyContactService; + private Mock _usersService; + private Mock _departmentsService; + private Mock _departmentGroupsService; + private Mock _personnelRolesService; + private Mock _actionLogsService; + private Mock _messageService; + private Mock _certificationService; + private Mock _trainingService; + private Mock _shiftsService; + private Mock _emailService; + private GdprDataExportRequest _request; + private GdprDataExportService _service; + + [SetUp] + public void SetUp() + { + _request = new GdprDataExportRequest + { + GdprDataExportRequestId = "req-1", + UserId = UserId, + DepartmentId = DeptId, + Status = (int)GdprExportStatus.Pending, + RequestedOn = DateTime.UtcNow + }; + + _repository = new Mock(); + _repository.Setup(x => x.GetPendingRequestsAsync()) + .ReturnsAsync(new List { _request }); + _repository.Setup(x => x.TryClaimForProcessingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + _repository.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns((r, ct, f) => Task.FromResult(r)); + + _userProfileService = new Mock(); + _userProfileService.Setup(x => x.GetProfileByUserIdAsync(UserId, It.IsAny())) + .ReturnsAsync(new UserProfile { UserId = UserId, FirstName = "Dana", LastName = "Reed", Language = "en" }); + + _memberSensitiveDataService = new Mock(); + _memberSensitiveDataService.Setup(x => x.GetResolvedForDepartmentAsync(DeptId, null, UserId)) + .ReturnsAsync(new Dictionary()); + + _emergencyContactService = new Mock(); + _emergencyContactService.Setup(x => x.GetAllForMemberAsync(DeptId, UserId)) + .ReturnsAsync(new List()); + + _usersService = new Mock(); + _usersService.Setup(x => x.GetUserById(UserId, It.IsAny())) + .Returns(new IdentityUser { Id = UserId, Email = "dana@example.org", UserName = "dana" }); + + _departmentsService = new Mock(); + _departmentGroupsService = new Mock(); + _personnelRolesService = new Mock(); + + _actionLogsService = new Mock(); + _actionLogsService.Setup(x => x.GetAllActionLogsForUser(UserId)).ReturnsAsync(new List()); + + _messageService = new Mock(); + _messageService.Setup(x => x.GetInboxMessagesByUserIdAsync(UserId)).ReturnsAsync(new List()); + _messageService.Setup(x => x.GetSentMessagesByUserIdAsync(UserId)).ReturnsAsync(new List()); + + _certificationService = new Mock(); + _certificationService.Setup(x => x.GetCertificationsByUserIdAsync(UserId)) + .ReturnsAsync(new List()); + + _trainingService = new Mock(); + _trainingService.Setup(x => x.GetTrainingUsersForUserAsync(UserId)).ReturnsAsync(new List()); + + _shiftsService = new Mock(); + _shiftsService.Setup(x => x.GetShiftPersonsForUserAsync(UserId)).ReturnsAsync(new List()); + + _emailService = new Mock(); + + _service = new GdprDataExportService(_repository.Object, _userProfileService.Object, + _memberSensitiveDataService.Object, _emergencyContactService.Object, _usersService.Object, + _departmentsService.Object, _departmentGroupsService.Object, _personnelRolesService.Object, + _actionLogsService.Object, _messageService.Object, _certificationService.Object, + _trainingService.Object, _shiftsService.Object, _emailService.Object); + } + + private async Task> RunExportAsync() + { + await _service.ProcessPendingRequestsAsync(CancellationToken.None); + + _request.ExportData.Should().NotBeNull(); + + var files = new Dictionary(StringComparer.Ordinal); + using var ms = new MemoryStream(_request.ExportData); + using var archive = new ZipArchive(ms, ZipArchiveMode.Read); + foreach (var entry in archive.Entries) + { + using var stream = entry.Open(); + using var reader = new StreamReader(stream, Encoding.UTF8); + files[entry.FullName] = await reader.ReadToEndAsync(); + } + + return files; + } + + [Test] + public async Task A_text_envelope_never_reaches_the_archive() + { + // PersonnelCertification.Name is named as protected content in the plan's Personnel + // family. The day it enters the catalog this entry starts carrying envelopes, and there + // is no reveal step in a background job to turn them back into values. + _certificationService.Setup(x => x.GetCertificationsByUserIdAsync(UserId)) + .ReturnsAsync(new List + { + new PersonnelCertification + { + PersonnelCertificationId = 1, + UserId = UserId, + DepartmentId = DeptId, + Name = "rgdp:1:2:c29tZS1jaXBoZXJ0ZXh0", + Number = "12345" + } + }); + + var files = await RunExportAsync(); + + files["certifications.json"].Should().NotContain("rgdp:"); + files["certifications.json"].Should().Contain(ProtectedDataEnvelope.RedactionValue); + + // Non-protected values on the same row are untouched — this is a per-value redaction, not + // a dropped record. + files["certifications.json"].Should().Contain("12345"); + } + + [Test] + public async Task A_binary_envelope_serialized_as_base64_never_reaches_the_archive() + { + // A byte[] carrying the rgdpb prefix reaches JSON as base64, so a prefix check on the + // decoded text alone would miss it. + var payload = Encoding.ASCII.GetBytes(ProtectedDataEnvelope.BinaryPrefix) + .Concat(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }).ToArray(); + _userProfileService.Setup(x => x.GetProfileByUserIdAsync(UserId, It.IsAny())) + .ReturnsAsync(new UserProfile { UserId = UserId, FirstName = "Dana", Language = "en", Image = payload }); + + var files = await RunExportAsync(); + + var base64Prefix = Convert.ToBase64String(Encoding.ASCII.GetBytes(ProtectedDataEnvelope.BinaryPrefix)); + files["profile.json"].Should().NotContain(base64Prefix); + files["profile.json"].Should().Contain(ProtectedDataEnvelope.RedactionValue); + } + + [Test] + public async Task What_was_withheld_is_declared_in_a_manifest() + { + _certificationService.Setup(x => x.GetCertificationsByUserIdAsync(UserId)) + .ReturnsAsync(new List + { + new PersonnelCertification { PersonnelCertificationId = 1, UserId = UserId, Name = "rgdp:1:2:aaa" }, + new PersonnelCertification { PersonnelCertificationId = 2, UserId = UserId, Name = "rgdp:1:2:bbb" } + }); + + var files = await RunExportAsync(); + + files.Should().ContainKey("withheld.json"); + var manifest = JObject.Parse(files["withheld.json"]); + + manifest["totalValuesWithheld"].Value().Should().Be(2); + manifest["placeholder"].Value().Should().Be(ProtectedDataEnvelope.RedactionValue); + manifest["notice"].Value().Should().NotBeNullOrWhiteSpace(); + manifest["howToObtain"].Value().Should().NotBeNullOrWhiteSpace(); + + // Array indices collapse, so two withheld rows name the field once rather than twice. + // Paths carry the serialized property casing, which is the casing in the archive. + var fields = manifest["entries"]["certifications.json"]["fields"].Values().ToList(); + fields.Should().ContainSingle().Which.Should().Be("[].Name"); + manifest["entries"]["certifications.json"]["valuesWithheld"].Value().Should().Be(2); + } + + [Test] + public async Task Values_already_redacted_upstream_are_reported_too() + { + // Membership data is resolved through the read pipeline, so it arrives as REDACTED rather + // than ciphertext. The manifest would be lying if it said nothing was withheld from an + // entry the member can see gaps in. + _memberSensitiveDataService.Setup(x => x.GetResolvedForDepartmentAsync(DeptId, null, UserId)) + .ReturnsAsync(new Dictionary + { + [UserId] = new DepartmentMemberSensitiveData + { + DepartmentId = DeptId, + UserId = UserId, + IdentificationNumber = ProtectedDataEnvelope.RedactionValue + } + }); + + var files = await RunExportAsync(); + + files.Should().ContainKey("withheld.json"); + var manifest = JObject.Parse(files["withheld.json"]); + manifest["entries"]["membership.json"]["fields"].Values() + .Should().Contain(f => f.Contains("dentificationNumber")); + } + + [Test] + public async Task An_unprotected_department_gets_no_manifest() + { + var files = await RunExportAsync(); + + files.Should().NotContainKey("withheld.json"); + files.Should().ContainKey("profile.json"); + files.Should().ContainKey("membership.json"); + } + + [Test] + public async Task The_manifest_speaks_the_members_language() + { + _userProfileService.Setup(x => x.GetProfileByUserIdAsync(UserId, It.IsAny())) + .ReturnsAsync(new UserProfile { UserId = UserId, FirstName = "Dana", Language = "de" }); + _certificationService.Setup(x => x.GetCertificationsByUserIdAsync(UserId)) + .ReturnsAsync(new List + { + new PersonnelCertification { PersonnelCertificationId = 1, UserId = UserId, Name = "rgdp:1:2:aaa" } + }); + + var files = await RunExportAsync(); + + var manifest = JObject.Parse(files["withheld.json"]); + manifest["notice"].Value().Should().Contain("Datenschutz"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/MemberDataDeletionTests.cs b/Tests/Resgrid.Tests/Services/MemberDataDeletionTests.cs new file mode 100644 index 000000000..8d76012d6 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/MemberDataDeletionTests.cs @@ -0,0 +1,99 @@ +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.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Deleting an account has to reach the department-scoped tables. Since the identification + /// number and addresses moved off the global UserProfiles row (ADP plan 5.1), scrubbing the + /// profile and deleting the legacy Addresses rows no longer removes them — those rows are now + /// the only copy. + /// + [TestFixture] + public class MemberDataDeletionTests + { + private const int DeptId = 8; + private const string UserId = "user-1"; + + [Test] + public async Task Deleting_a_member_removes_their_department_scoped_row() + { + var repo = new Mock(); + var row = new DepartmentMemberSensitiveData + { + DepartmentMemberSensitiveDataId = 3, + DepartmentId = DeptId, + UserId = UserId, + IdentificationNumber = "BADGE-1" + }; + repo.Setup(x => x.GetByDepartmentAndUserAsync(DeptId, UserId)).ReturnsAsync(row); + repo.Setup(x => x.DeleteAsync(row, It.IsAny())).ReturnsAsync(true); + + var service = new DepartmentMemberSensitiveDataService(repo.Object, + new Lazy(() => new Mock().Object), + new Lazy(() => new Mock().Object)); + + var deleted = await service.DeleteForMemberAsync(DeptId, UserId); + + deleted.Should().BeTrue(); + repo.Verify(x => x.DeleteAsync(row, It.IsAny()), Times.Once); + } + + [Test] + public async Task Deleting_a_member_with_no_row_is_a_no_op() + { + var repo = new Mock(); + repo.Setup(x => x.GetByDepartmentAndUserAsync(DeptId, UserId)) + .ReturnsAsync((DepartmentMemberSensitiveData)null); + + var service = new DepartmentMemberSensitiveDataService(repo.Object, + new Lazy(() => new Mock().Object), + new Lazy(() => new Mock().Object)); + + (await service.DeleteForMemberAsync(DeptId, UserId)).Should().BeFalse(); + repo.Verify(x => x.DeleteAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task Emergency_contacts_are_hard_deleted_including_already_soft_deleted_rows() + { + // The per-contact delete is a soft delete, which keeps envelopes and residue counts + // consistent. Account deletion is different: a soft delete would leave a third party's + // name and phone number in the table. The bulk path goes straight to a DELETE rather + // than through the IsDeleted-filtered getter, so soft-deleted rows go too. + var repo = new Mock(); + repo.Setup(x => x.DeleteAllByDepartmentAndUserAsync(DeptId, UserId)).ReturnsAsync(3); + + var service = new DepartmentMemberEmergencyContactService(repo.Object, + new Lazy(() => new Mock().Object)); + + (await service.DeleteAllForMemberAsync(DeptId, UserId)).Should().Be(3); + + repo.Verify(x => x.DeleteAllByDepartmentAndUserAsync(DeptId, UserId), Times.Once); + repo.Verify(x => x.GetAllByDepartmentAndUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Bulk_emergency_contact_deletion_ignores_an_unusable_scope() + { + var repo = new Mock(); + var service = new DepartmentMemberEmergencyContactService(repo.Object, + new Lazy(() => new Mock().Object)); + + (await service.DeleteAllForMemberAsync(0, UserId)).Should().Be(0); + (await service.DeleteAllForMemberAsync(DeptId, " ")).Should().Be(0); + + repo.Verify(x => x.DeleteAllByDepartmentAndUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/MemberEmergencyContactProtectionTests.cs b/Tests/Resgrid.Tests/Services/MemberEmergencyContactProtectionTests.cs new file mode 100644 index 000000000..f22f71cff --- /dev/null +++ b/Tests/Resgrid.Tests/Services/MemberEmergencyContactProtectionTests.cs @@ -0,0 +1,183 @@ +using System; +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 +{ + /// + /// A member's emergency contacts are department-scoped and there may be several. The write net + /// envelopes them for a protected department; deletes are scoped by department AND user so an id + /// alone can never reach another member's row. + /// + [TestFixture] + public class MemberEmergencyContactProtectionTests + { + private const int DeptId = 10; + private const string UserId = "user-1"; + + private Mock _repo; + private Mock _protectedWriteService; + private DepartmentMemberEmergencyContactService _service; + + [SetUp] + public void SetUp() + { + _repo = new Mock(); + _repo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentMemberEmergencyContact c, CancellationToken _, bool __) => c); + + _protectedWriteService = new Mock(); + _protectedWriteService.Setup(x => x.PrepareMemberEmergencyContactWriteAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + + _service = new DepartmentMemberEmergencyContactService(_repo.Object, + new Lazy(() => _protectedWriteService.Object)); + } + + private static DepartmentMemberEmergencyContact BuildContact(int id = 3) => new DepartmentMemberEmergencyContact + { + DepartmentMemberEmergencyContactId = id, + DepartmentId = DeptId, + UserId = UserId, + Name = "Jamie Doe", + Relationship = "Spouse", + PhoneNumber = "555-0100" + }; + + [Test] + public async Task Saving_a_primary_contact_demotes_the_previous_one() + { + // "Who do we call first" has to have a single answer, and nothing in the schema enforces + // it — the service has to. + var previous = new DepartmentMemberEmergencyContact + { + DepartmentMemberEmergencyContactId = 1, + DepartmentId = DeptId, + UserId = UserId, + Name = "Old Primary", + IsPrimary = true + }; + + _repo.Setup(x => x.GetAllByDepartmentAndUserAsync(DeptId, UserId)) + .ReturnsAsync(new[] { previous }); + + var incoming = BuildContact(2); + incoming.IsPrimary = true; + + await _service.SaveAsync(incoming); + + previous.IsPrimary.Should().BeFalse(); + _repo.Verify(x => x.SaveOrUpdateAsync(previous, It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Saving_a_non_primary_contact_leaves_the_existing_primary_alone() + { + var primary = new DepartmentMemberEmergencyContact + { + DepartmentMemberEmergencyContactId = 1, + DepartmentId = DeptId, + UserId = UserId, + IsPrimary = true + }; + + _repo.Setup(x => x.GetAllByDepartmentAndUserAsync(DeptId, UserId)) + .ReturnsAsync(new[] { primary }); + + var incoming = BuildContact(2); + incoming.IsPrimary = false; + + await _service.SaveAsync(incoming); + + primary.IsPrimary.Should().BeTrue(); + _repo.Verify(x => x.SaveOrUpdateAsync(primary, It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Re_saving_the_same_primary_does_not_demote_itself() + { + var contact = BuildContact(5); + contact.IsPrimary = true; + + _repo.Setup(x => x.GetAllByDepartmentAndUserAsync(DeptId, UserId)) + .ReturnsAsync(new[] { contact }); + + var saved = await _service.SaveAsync(contact); + + saved.IsPrimary.Should().BeTrue(); + } + + [Test] + public async Task Protected_department_repersists_the_enveloped_contact() + { + _protectedWriteService.Setup(x => x.PrepareMemberEmergencyContactWriteAsync(DeptId, + It.IsAny(), null, null, true, It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed(isProtected: true, changed: true)); + + await _service.SaveAsync(BuildContact()); + + _repo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Exactly(2), "initial save plus the re-save that persists the envelopes"); + } + + [Test] + public async Task Blocked_protected_write_throws_rather_than_leaving_plaintext() + { + _protectedWriteService.Setup(x => x.PrepareMemberEmergencyContactWriteAsync(DeptId, + It.IsAny(), null, null, true, It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Blocked("broker_unavailable")); + + Func act = async () => await _service.SaveAsync(BuildContact()); + + await act.Should().ThrowAsync().WithMessage("*broker_unavailable*"); + } + + [Test] + public async Task A_member_can_hold_several_contacts_for_one_department() + { + _repo.Setup(x => x.GetAllByDepartmentAndUserAsync(DeptId, UserId)) + .ReturnsAsync(new[] { BuildContact(1), BuildContact(2), BuildContact(3) }); + + var contacts = await _service.GetAllForMemberAsync(DeptId, UserId); + + contacts.Should().HaveCount(3); + } + + [Test] + public async Task Delete_refuses_an_id_that_belongs_to_another_member() + { + // The repository lookup is already scoped to (department, user); an id outside that set + // must not be deletable, or one member could remove another's next-of-kin details. + _repo.Setup(x => x.GetAllByDepartmentAndUserAsync(DeptId, UserId)) + .ReturnsAsync(new[] { BuildContact(1) }); + + var deleted = await _service.DeleteAsync(99, DeptId, UserId, UserId); + + deleted.Should().BeFalse(); + _repo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task Delete_is_a_soft_delete_so_envelopes_survive() + { + var contact = BuildContact(1); + _repo.Setup(x => x.GetAllByDepartmentAndUserAsync(DeptId, UserId)).ReturnsAsync(new[] { contact }); + + var deleted = await _service.DeleteAsync(1, DeptId, UserId, "admin-1"); + + deleted.Should().BeTrue(); + contact.IsDeleted.Should().BeTrue(); + contact.UpdatedByUserId.Should().Be("admin-1"); + contact.Name.Should().Be("Jamie Doe", "a soft delete must not need to decrypt anything"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/MemberIdentificationNumberScopeTests.cs b/Tests/Resgrid.Tests/Services/MemberIdentificationNumberScopeTests.cs new file mode 100644 index 000000000..74ccfb1e5 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/MemberIdentificationNumberScopeTests.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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 +{ + /// + /// A member's identification number is DEPARTMENT-issued (ADP plan 5.1). UserProfile is global + /// to the user and shared across every department they belong to, so it can neither be encrypted + /// with one department's key nor hold the different numbers different departments issue the same + /// person. These pin that the department-scoped value always wins. + /// + [TestFixture] + public class MemberIdentificationNumberScopeTests + { + private const int DeptId = 10; + + private Mock _repo; + private Mock _protectedReadService; + private DepartmentMemberSensitiveDataService _service; + + [SetUp] + public void SetUp() + { + _repo = new Mock(); + _protectedReadService = new Mock(); + _protectedReadService.Setup(x => x.ResolveMemberSensitiveDataForReadAsync(It.IsAny(), + It.IsAny>(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProtectedReadResult()); + + _service = new DepartmentMemberSensitiveDataService(_repo.Object, + new Lazy(() => Mock.Of()), + new Lazy(() => _protectedReadService.Object)); + } + + [Test] + public async Task The_departments_number_replaces_the_global_profile_value() + { + _repo.Setup(x => x.GetAllByDepartmentIdAsync(DeptId)).ReturnsAsync(new[] + { + new DepartmentMemberSensitiveData { DepartmentId = DeptId, UserId = "user-1", IdentificationNumber = "DEPT-A-77" } + }); + + // The profile carries a number issued by a DIFFERENT department; it must not leak here. + var profile = new UserProfile { UserId = "user-1", IdentificationNumber = "OTHER-DEPT-12" }; + + await _service.ApplyIdentificationNumbersAsync(DeptId, new[] { profile }, null, "actor"); + + profile.IdentificationNumber.Should().Be("DEPT-A-77"); + } + + [Test] + public async Task A_member_with_no_row_for_this_department_has_no_number() + { + _repo.Setup(x => x.GetAllByDepartmentIdAsync(DeptId)) + .ReturnsAsync(Array.Empty()); + + var profile = new UserProfile { UserId = "user-1", IdentificationNumber = "OTHER-DEPT-12" }; + + await _service.ApplyIdentificationNumbersAsync(DeptId, new[] { profile }, null, "actor"); + + profile.IdentificationNumber.Should().BeNull( + "the legacy global column must never answer for a department that issued no number"); + } + + [Test] + public async Task Values_are_resolved_through_the_protected_pipeline_before_being_applied() + { + _repo.Setup(x => x.GetAllByDepartmentIdAsync(DeptId)).ReturnsAsync(new[] + { + new DepartmentMemberSensitiveData { DepartmentId = DeptId, UserId = "user-1", IdentificationNumber = "rgdp:1:1:id==" } + }); + + var profile = new UserProfile { UserId = "user-1" }; + + await _service.ApplyIdentificationNumbersAsync(DeptId, new[] { profile }, "grant", "actor"); + + _protectedReadService.Verify(x => x.ResolveMemberSensitiveDataForReadAsync(DeptId, + It.IsAny>(), "grant", "actor", It.IsAny()), + Times.Once, "ciphertext must never reach a report or API DTO"); + } + + [Test] + public async Task User_id_matching_is_case_insensitive() + { + _repo.Setup(x => x.GetAllByDepartmentIdAsync(DeptId)).ReturnsAsync(new[] + { + new DepartmentMemberSensitiveData { DepartmentId = DeptId, UserId = "USER-1", IdentificationNumber = "DEPT-A-77" } + }); + + var profile = new UserProfile { UserId = "user-1" }; + + await _service.ApplyIdentificationNumbersAsync(DeptId, new[] { profile }, null, "actor"); + + profile.IdentificationNumber.Should().Be("DEPT-A-77", "identity user ids vary in casing across stores"); + } + + [Test] + public async Task An_empty_profile_list_does_no_work() + { + await _service.ApplyIdentificationNumbersAsync(DeptId, Array.Empty(), null, "actor"); + await _service.ApplyIdentificationNumbersAsync(DeptId, null, null, "actor"); + + _repo.Verify(x => x.GetAllByDepartmentIdAsync(It.IsAny()), Times.Never, + "a report with no personnel must not pay for a query or a broker round trip"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/MemberProfileRelocationTests.cs b/Tests/Resgrid.Tests/Services/MemberProfileRelocationTests.cs new file mode 100644 index 000000000..e9ded5e38 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/MemberProfileRelocationTests.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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 +{ + /// + /// Moving a member's identification number and addresses off the global UserProfiles row onto + /// their department-scoped row (ADP plan 5.1). M0134 does this in SQL for departments that are + /// still plaintext; this service is the path for the rest — already-enrolled departments, where + /// the value has to be enveloped as it lands, and members who join after the migration ran. + /// + /// The invariants that matter: never overwrite a value the department already has, never push a + /// legacy value back onto a member who deliberately cleared theirs, and always leave a marker so + /// the backlog actually drains. + /// + [TestFixture] + public class MemberProfileRelocationTests + { + private const int DeptId = 42; + + private Mock _repo; + private Mock _sensitiveDataService; + private Mock _userProfileService; + private Mock _addressService; + private List _saved; + private MemberProfileRelocationService _service; + + [SetUp] + public void SetUp() + { + _repo = new Mock(); + _sensitiveDataService = new Mock(); + _userProfileService = new Mock(); + _addressService = new Mock(); + _saved = new List(); + + _sensitiveDataService.Setup(x => x.SaveAsync(It.IsAny(), + It.IsAny())) + .Returns((d, ct) => + { + _saved.Add(d); + return Task.FromResult(d); + }); + + _repo.Setup(x => x.GetAllByDepartmentIdAsync(It.IsAny())) + .ReturnsAsync(new List()); + + _service = new MemberProfileRelocationService(_repo.Object, _sensitiveDataService.Object, + _userProfileService.Object, _addressService.Object); + } + + private void SetupProfiles(params UserProfile[] profiles) => + _userProfileService.Setup(x => x.GetAllProfilesForDepartmentIncDisabledDeletedAsync(DeptId)) + .ReturnsAsync(profiles.ToDictionary(p => p.UserId)); + + private void SetupRows(params DepartmentMemberSensitiveData[] rows) => + _repo.Setup(x => x.GetAllByDepartmentIdAsync(DeptId)).ReturnsAsync(rows.ToList()); + + private void SetupAddress(int addressId, string line1) => + _addressService.Setup(x => x.GetAddressByIdAsync(addressId)) + .ReturnsAsync(new Address + { + AddressId = addressId, + Address1 = line1, + City = "Springfield", + State = "IL", + PostalCode = "62701", + Country = "US" + }); + + [Test] + public async Task Legacy_identification_number_and_addresses_move_onto_the_department_row() + { + SetupProfiles(new UserProfile + { + UserId = "user-1", + IdentificationNumber = "BADGE-7", + HomeAddressId = 100, + MailingAddressId = 200 + }); + SetupAddress(100, "1 Home Street"); + SetupAddress(200, "2 Mailing Street"); + + var result = await _service.RelocateDepartmentAsync(DeptId); + + result.RowsCreated.Should().Be(1); + result.IdentificationNumbersMoved.Should().Be(1); + result.AddressesMoved.Should().Be(2); + result.Failures.Should().Be(0); + + var row = _saved.Single(); + row.DepartmentId.Should().Be(DeptId); + row.IdentificationNumber.Should().Be("BADGE-7"); + row.HomeAddress1.Should().Be("1 Home Street"); + row.HomeCity.Should().Be("Springfield"); + row.HomePostalCode.Should().Be("62701"); + row.MailingAddress1.Should().Be("2 Mailing Street"); + row.LegacyProfileRelocatedOn.Should().NotBeNull(); + } + + [Test] + public async Task A_department_specific_value_is_never_overwritten() + { + // The whole point of the move is that these differ per department. Anything already in + // the target wins over whatever the shared profile happens to hold. + SetupProfiles(new UserProfile + { + UserId = "user-1", + IdentificationNumber = "GLOBAL-1", + HomeAddressId = 100, + MailingAddressId = 200 + }); + SetupRows(new DepartmentMemberSensitiveData + { + DepartmentMemberSensitiveDataId = 5, + DepartmentId = DeptId, + UserId = "user-1", + IdentificationNumber = "DEPT-9", + HomeAddress1 = "9 Department Road" + }); + SetupAddress(100, "1 Home Street"); + SetupAddress(200, "2 Mailing Street"); + + var result = await _service.RelocateDepartmentAsync(DeptId); + + var row = _saved.Single(); + row.IdentificationNumber.Should().Be("DEPT-9"); + row.HomeAddress1.Should().Be("9 Department Road"); + + // The empty one still gets filled — this is a per-field move, not all-or-nothing. + row.MailingAddress1.Should().Be("2 Mailing Street"); + result.IdentificationNumbersMoved.Should().Be(0); + result.AddressesMoved.Should().Be(1); + result.RowsCreated.Should().Be(0); + } + + [Test] + public async Task An_already_marked_member_is_left_alone() + { + SetupProfiles(new UserProfile { UserId = "user-1", IdentificationNumber = "BADGE-7" }); + SetupRows(new DepartmentMemberSensitiveData + { + DepartmentMemberSensitiveDataId = 5, + DepartmentId = DeptId, + UserId = "user-1", + LegacyProfileRelocatedOn = DateTime.UtcNow.AddDays(-3) + }); + + var result = await _service.RelocateDepartmentAsync(DeptId); + + result.MembersExamined.Should().Be(0); + _saved.Should().BeEmpty(); + } + + [Test] + public async Task A_cleared_department_value_is_not_refilled_from_the_legacy_profile() + { + // A member who deliberately clears their department identification number must not have + // the old global one pushed back onto them by the next pass. This is exactly why + // relocation keys off a marker instead of "is the target empty?". + SetupProfiles(new UserProfile { UserId = "user-1", IdentificationNumber = "BADGE-7" }); + SetupRows(new DepartmentMemberSensitiveData + { + DepartmentMemberSensitiveDataId = 5, + DepartmentId = DeptId, + UserId = "user-1", + IdentificationNumber = null, + LegacyProfileRelocatedOn = DateTime.UtcNow.AddMinutes(-5) + }); + + await _service.RelocateDepartmentAsync(DeptId); + + _saved.Should().BeEmpty(); + } + + [Test] + public async Task A_target_holding_an_envelope_counts_as_populated() + { + // An enrolled department's row holds ciphertext, which is a value like any other. Writing + // the legacy plaintext over it would destroy the protected copy. + SetupProfiles(new UserProfile { UserId = "user-1", IdentificationNumber = "BADGE-7" }); + SetupRows(new DepartmentMemberSensitiveData + { + DepartmentMemberSensitiveDataId = 5, + DepartmentId = DeptId, + UserId = "user-1", + IdentificationNumber = "rgdp:1:3:c29tZS1jaXBoZXJ0ZXh0", + IsProtected = true + }); + + var result = await _service.RelocateDepartmentAsync(DeptId); + + _saved.Single().IdentificationNumber.Should().Be("rgdp:1:3:c29tZS1jaXBoZXJ0ZXh0"); + result.IdentificationNumbersMoved.Should().Be(0); + + // Still marked: this member has been through relocation, there was simply nothing to move. + _saved.Single().LegacyProfileRelocatedOn.Should().NotBeNull(); + } + + [Test] + public async Task A_member_with_nothing_to_move_is_still_marked() + { + // Otherwise every member who never filled in an address keeps the backlog non-empty + // forever, and the contract migration can never be cleared to run. + SetupProfiles(new UserProfile { UserId = "user-1" }); + + var result = await _service.RelocateDepartmentAsync(DeptId); + + result.MembersExamined.Should().Be(1); + _saved.Single().LegacyProfileRelocatedOn.Should().NotBeNull(); + } + + [Test] + public async Task One_failing_member_does_not_strand_the_rest_of_the_department() + { + SetupProfiles( + new UserProfile { UserId = "user-1", IdentificationNumber = "BADGE-1" }, + new UserProfile { UserId = "user-2", IdentificationNumber = "BADGE-2" }); + + _sensitiveDataService.Setup(x => x.SaveAsync( + It.Is(d => d.UserId == "user-1"), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Protected write blocked (broker_unavailable)")); + + var result = await _service.RelocateDepartmentAsync(DeptId); + + result.Failures.Should().Be(1); + result.MembersExamined.Should().Be(2); + + // The survivor is saved and marked; the failure never persisted a marker, so the next + // pass retries it. + _saved.Single().UserId.Should().Be("user-2"); + } + + [Test] + public async Task A_missing_legacy_address_row_is_not_treated_as_a_move() + { + // A dangling HomeAddressId (deleted address) must leave the target empty rather than + // writing blanks over it, and must not be counted as relocated data. + SetupProfiles(new UserProfile { UserId = "user-1", HomeAddressId = 100 }); + _addressService.Setup(x => x.GetAddressByIdAsync(100)).ReturnsAsync((Address)null); + + var result = await _service.RelocateDepartmentAsync(DeptId); + + result.AddressesMoved.Should().Be(0); + _saved.Single().HomeAddress1.Should().BeNull(); + } + + [Test] + public async Task Outstanding_departments_come_back_deduplicated_and_ordered() + { + _repo.Setup(x => x.GetDepartmentIdsWithOutstandingLegacyProfileDataAsync()) + .ReturnsAsync(new[] { 7, 3, 7, 11 }); + + var ids = await _service.GetDepartmentIdsWithOutstandingDataAsync(); + + ids.Should().Equal(3, 7, 11); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedDataEnvelopeTests.cs b/Tests/Resgrid.Tests/Services/ProtectedDataEnvelopeTests.cs index bb67fb55f..f2946b6a3 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedDataEnvelopeTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedDataEnvelopeTests.cs @@ -69,5 +69,32 @@ public void Redaction_value_is_the_exact_contract_string() { ProtectedDataEnvelope.RedactionValue.Should().Be("REDACTED"); } + + /// + /// SafeDisplay is the shared guard for every surface that has no reveal pipeline — + /// server-rendered lists, reports and exports, grid JSON, search terms. Ciphertext of + /// either envelope kind must become the placeholder; everything else passes through + /// untouched (an unprotected department's values must not be altered). + /// + [Test] + public void Safe_display_replaces_ciphertext_and_passes_everything_else_through() + { + ProtectedDataEnvelope.SafeDisplay("rgdp:1:1:abc==").Should().Be(ProtectedDataEnvelope.RedactionValue); + ProtectedDataEnvelope.SafeDisplay("rgdpb:1:1:abc==").Should().Be(ProtectedDataEnvelope.RedactionValue); + + ProtectedDataEnvelope.SafeDisplay("Structure Fire").Should().Be("Structure Fire"); + ProtectedDataEnvelope.SafeDisplay(ProtectedDataEnvelope.RedactionValue).Should().Be(ProtectedDataEnvelope.RedactionValue); + ProtectedDataEnvelope.SafeDisplay("").Should().BeEmpty(); + ProtectedDataEnvelope.SafeDisplay(null).Should().BeNull(); + } + + [Test] + public void Safe_display_does_not_match_a_value_that_merely_mentions_the_prefix() + { + // The check is a PREFIX test, not a contains test: a note that quotes the format must + // still render normally. + ProtectedDataEnvelope.SafeDisplay("caller said rgdp:1:1:looks-like-this") + .Should().Be("caller said rgdp:1:1:looks-like-this"); + } } } diff --git a/Tests/Resgrid.Tests/Services/ProtectedEgressScannerTests.cs b/Tests/Resgrid.Tests/Services/ProtectedEgressScannerTests.cs new file mode 100644 index 000000000..bbccba211 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedEgressScannerTests.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; + +namespace Resgrid.Tests.Services +{ + /// + /// The response-boundary net (ADP plan 7.5). The catalog proves a field is protected and the + /// binding-parity guard proves a read accessor exists, but nothing proves a surface CALLS the + /// resolve method — four real leaks were found by hand and every one was invisible to the + /// suite. This walks whatever is about to leave and redacts what still carries an envelope. + /// + [TestFixture] + public class ProtectedEgressScannerTests + { + private const string Envelope = "rgdp:1:2:c29tZS1jaXBoZXJ0ZXh0"; + + private static byte[] BinaryEnvelope() => + Encoding.ASCII.GetBytes(ProtectedDataEnvelope.BinaryPrefix).Concat(new byte[] { 1, 2, 3 }).ToArray(); + + public class Leaky + { + public string Name { get; set; } + public string Plain { get; set; } + public byte[] Payload { get; set; } + public Leaky Child { get; set; } + public List Children { get; set; } + public List Values { get; set; } + public Dictionary Map { get; set; } + public string ReadOnlyEnvelope => Envelope; + public string Throws => throw new InvalidOperationException("computed getter blew up"); + public Uri External { get; set; } + } + + [Test] + public void An_enveloped_string_is_replaced_with_the_placeholder() + { + var model = new Leaky { Name = Envelope, Plain = "Engine 1" }; + + var result = ProtectedEgressScanner.Sanitize(model); + + model.Name.Should().Be(ProtectedDataEnvelope.RedactionValue); + model.Plain.Should().Be("Engine 1", "values that were never protected are untouched"); + result.Redacted.Should().BeGreaterThan(0); + result.Paths.Should().Contain(p => p.EndsWith(".Name")); + } + + [Test] + public void An_enveloped_binary_payload_is_nulled_rather_than_served() + { + // There is no readable placeholder for a file; ciphertext bytes must simply not go out. + var model = new Leaky { Payload = BinaryEnvelope() }; + + ProtectedEgressScanner.Sanitize(model); + + model.Payload.Should().BeNull(); + } + + [Test] + public void Nested_objects_and_collections_are_walked() + { + var model = new Leaky + { + Child = new Leaky { Name = Envelope }, + Children = new List { new Leaky { Name = Envelope }, new Leaky { Name = "fine" } }, + Values = new List { Envelope, "fine" }, + Map = new Dictionary { ["a"] = Envelope, ["b"] = "fine" } + }; + + ProtectedEgressScanner.Sanitize(model); + + model.Child.Name.Should().Be(ProtectedDataEnvelope.RedactionValue); + model.Children[0].Name.Should().Be(ProtectedDataEnvelope.RedactionValue); + model.Children[1].Name.Should().Be("fine"); + model.Values[0].Should().Be(ProtectedDataEnvelope.RedactionValue); + model.Values[1].Should().Be("fine"); + model.Map["a"].Should().Be(ProtectedDataEnvelope.RedactionValue); + model.Map["b"].Should().Be("fine"); + } + + [Test] + public void A_reference_cycle_does_not_hang_the_walk() + { + var a = new Leaky { Name = Envelope }; + var b = new Leaky { Child = a }; + a.Child = b; + + var result = ProtectedEgressScanner.Sanitize(a); + + a.Name.Should().Be(ProtectedDataEnvelope.RedactionValue); + result.Truncated.Should().BeFalse("the visited set should end the walk, not the node budget"); + } + + [Test] + public void A_read_only_member_is_reported_rather_than_silently_passed() + { + // Nothing can be done about it here, so the count and path have to say so — claiming a + // clean scan when ciphertext is still leaving would be worse than not scanning. + var result = ProtectedEgressScanner.Sanitize(new Leaky()); + + result.Unfixable.Should().BeGreaterThan(0); + result.Paths.Should().Contain(p => p.Contains("ReadOnlyEnvelope")); + } + + [Test] + public void A_throwing_getter_is_skipped_instead_of_breaking_the_response() + { + var model = new Leaky { Name = Envelope }; + + Action scan = () => ProtectedEgressScanner.Sanitize(model); + + scan.Should().NotThrow(); + model.Name.Should().Be(ProtectedDataEnvelope.RedactionValue, "the rest of the graph is still scanned"); + } + + [Test] + public void The_walk_stays_inside_the_resgrid_object_model() + { + // A view model that happens to hold a framework object must not drag the walk into it. + var model = new Leaky { External = new Uri("https://example.org/a") }; + + Action scan = () => ProtectedEgressScanner.Sanitize(model); + + scan.Should().NotThrow(); + } + + [Test] + public void A_graph_over_the_node_budget_reports_truncation() + { + // A silent cap would read as "nothing found" on exactly the large payloads most likely + // to be carrying something. + var root = new Leaky { Children = new List() }; + for (var i = 0; i < 50; i++) + root.Children.Add(new Leaky { Name = "fine" }); + + var result = ProtectedEgressScanner.Sanitize(root, maxNodes: 5); + + result.Truncated.Should().BeTrue(); + } + + [Test] + public void Null_and_empty_graphs_are_handled() + { + ProtectedEgressScanner.Sanitize(null).FoundAnything.Should().BeFalse(); + ProtectedEgressScanner.Sanitize(new Leaky()).Redacted.Should().Be(0); + } + + [Test] + public void Binary_prefix_detection_matches_only_the_marker() + { + ProtectedEgressScanner.HasBinaryEnvelopePrefix(BinaryEnvelope()).Should().BeTrue(); + ProtectedEgressScanner.HasBinaryEnvelopePrefix(new byte[] { 1, 2, 3 }).Should().BeFalse(); + ProtectedEgressScanner.HasBinaryEnvelopePrefix(Array.Empty()).Should().BeFalse(); + ProtectedEgressScanner.HasBinaryEnvelopePrefix(null).Should().BeFalse(); + } + + [Test] + public void A_real_entity_graph_is_covered() + { + // The shape that actually leaked: an entity mapped into a result without resolving. + var call = new Call + { + CallId = 4, + Name = Envelope, + Number = "26-45", + NatureOfCall = Envelope, + CallNotes = new List { new CallNote { CallNoteId = 1, Note = Envelope } } + }; + + var result = ProtectedEgressScanner.Sanitize(call); + + call.Name.Should().Be(ProtectedDataEnvelope.RedactionValue); + call.NatureOfCall.Should().Be(ProtectedDataEnvelope.RedactionValue); + call.Number.Should().Be("26-45", "the call number is deliberately not cataloged"); + call.CallNotes.First().Note.Should().Be(ProtectedDataEnvelope.RedactionValue); + result.Redacted.Should().BeGreaterThanOrEqualTo(3); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedFieldCatalogTests.cs b/Tests/Resgrid.Tests/Services/ProtectedFieldCatalogTests.cs index 9110e0395..1227d6555 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedFieldCatalogTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedFieldCatalogTests.cs @@ -76,10 +76,70 @@ public void Permissions_follow_family_boundaries() } [Test] - public void Version_is_one_and_all_entries_belong_to_it() + public void Version_tracks_the_highest_entry_version() { - _catalog.Version.Should().Be(1); - _catalog.GetAll().Should().OnlyContain(e => e.AddedInCatalogVersion == 1); + // The constant must never lag the data: version-scoped queries and the upgrade work list + // both key off it, so a hand-maintained value that fell behind would quietly hand a v1 + // department fields it does not own. + _catalog.Version.Should().Be(_catalog.GetAll().Max(e => e.AddedInCatalogVersion)); + _catalog.GetAll().Should().OnlyContain(e => e.AddedInCatalogVersion >= 1); } - } + + [Test] + public void Catalog_versions_are_contiguous_from_one() + { + // A gap (v1 then v3) would leave an upgrade range that sweeps nothing while still + // bumping a department's pinned version past fields that were never encrypted. + var versions = _catalog.GetAll().Select(e => e.AddedInCatalogVersion).Distinct().OrderBy(v => v).ToList(); + + versions.Should().BeEquivalentTo(Enumerable.Range(1, _catalog.Version)); + } + + /// + /// Version scoping is what keeps a department that enrolled under an older catalog from + /// silently starting to encrypt fields added later: its AAD is computed from its pinned + /// version and those rows were never swept. AddedInCatalogVersion drives all of it. + /// + [Test] + public void Version_scoping_returns_only_what_a_pinned_department_owns() + { + var catalog = new ProtectedFieldCatalog(); + + catalog.GetAllForVersion(catalog.Version).Should().BeEquivalentTo(catalog.GetAll(), + "a current department owns the whole catalog"); + catalog.GetAllForVersion(catalog.Version + 5).Should().BeEquivalentTo(catalog.GetAll(), + "a version beyond the code's catalog still owns only what exists"); + catalog.GetAllForVersion(0).Should().BeEmpty("an unstamped department owns nothing"); + catalog.GetAllForVersion(-1).Should().BeEmpty(); + + catalog.GetAllForVersion(1).Should().OnlyContain(e => e.AddedInCatalogVersion <= 1); + } + + [Test] + public void Version_scoping_applies_per_table_too() + { + var catalog = new ProtectedFieldCatalog(); + + catalog.GetForTableAndVersion("Calls", catalog.Version) + .Should().BeEquivalentTo(catalog.GetForTable("Calls")); + catalog.GetForTableAndVersion("Calls", 0).Should().BeEmpty(); + catalog.GetForTableAndVersion("NoSuchTable", catalog.Version).Should().BeEmpty(); + } + + [Test] + public void Added_between_is_the_exact_upgrade_work_list() + { + var catalog = new ProtectedFieldCatalog(); + + catalog.GetAddedBetween(catalog.Version, catalog.Version) + .Should().BeEmpty("a current department is owed no upgrade sweep"); + catalog.GetAddedBetween(catalog.Version + 1, catalog.Version) + .Should().BeEmpty("a backwards range is not an upgrade"); + + // Everything in v1 is 'added between 0 and 1' — the enrollment sweep's own work list. + catalog.GetAddedBetween(0, 1).Should().OnlyContain(e => e.AddedInCatalogVersion == 1); + catalog.GetAddedBetween(0, catalog.Version).Should().BeEquivalentTo(catalog.GetAll(), + "a department starting from nothing is owed every cataloged field"); + } +} } diff --git a/Tests/Resgrid.Tests/Services/ProtectedFieldCryptoServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedFieldCryptoServiceTests.cs index 3d1762093..df44d0b6a 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedFieldCryptoServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedFieldCryptoServiceTests.cs @@ -25,46 +25,61 @@ public void SetUp() [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); + var envelope = _crypto.EncryptText(_dek, 3, "Chest pain, 62yo male", 42, "calls.natureofcall", "1001"); envelope.Should().StartWith("rgdp:1:3:"); ProtectedDataEnvelope.IsEnveloped(envelope).Should().BeTrue(); - _crypto.DecryptText(_dek, envelope, 42, "calls.natureofcall", "1001", 1) + _crypto.DecryptText(_dek, envelope, 42, "calls.natureofcall", "1001") .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) + [TestCase(43, "calls.natureofcall", "1001", Description = "different department")] + [TestCase(42, "calls.notes", "1001", Description = "different field")] + [TestCase(42, "calls.natureofcall", "1002", Description = "different row")] + public void Any_aad_component_mismatch_fails_authentication(int departmentId, string fieldId, string rowKey) { - var envelope = _crypto.EncryptText(_dek, 1, "secret", 42, "calls.natureofcall", "1001", 1); + var envelope = _crypto.EncryptText(_dek, 1, "secret", 42, "calls.natureofcall", "1001"); - var act = () => _crypto.DecryptText(_dek, envelope, departmentId, fieldId, rowKey, catalogVersion); + var act = () => _crypto.DecryptText(_dek, envelope, departmentId, fieldId, rowKey); act.Should().Throw( - "moving ciphertext between tenants, rows, fields, or catalog versions must fail AEAD authentication"); + "moving ciphertext between tenants, rows, or fields must fail AEAD authentication"); + } + + /// + /// The catalog version is deliberately NOT an AAD component. If it were, advancing a + /// department's pinned catalog version would make its entire stored corpus undecryptable, + /// so every catalog addition would force a full decrypt/re-encrypt of every protected row. + /// Field ids are stable forever, so the field id already provides the binding. + /// + [Test] + public void Catalog_version_is_not_bound_so_a_catalog_upgrade_never_breaks_stored_envelopes() + { + var envelope = _crypto.EncryptText(_dek, 1, "Chest pain, 62yo male", 42, "calls.natureofcall", "1001"); + + // Same department, field and row — the department has since moved to a later catalog. + _crypto.DecryptText(_dek, envelope, 42, "calls.natureofcall", "1001") + .Should().Be("Chest pain, 62yo male"); } [Test] public void Tampered_ciphertext_fails_authentication() { - var envelope = _crypto.EncryptText(_dek, 1, "secret", 42, "calls.notes", "7", 1); + var envelope = _crypto.EncryptText(_dek, 1, "secret", 42, "calls.notes", "7"); 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); + var act = () => _crypto.DecryptText(_dek, tampered, 42, "calls.notes", "7"); 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 envelope = _crypto.EncryptText(_dek, 1, "secret", 42, "calls.notes", "7"); - var act = () => _crypto.EncryptText(_dek, 1, envelope, 42, "calls.notes", "7", 1); + var act = () => _crypto.EncryptText(_dek, 1, envelope, 42, "calls.notes", "7"); act.Should().Throw( "the double-encryption guard must make re-encrypting an envelope impossible"); } @@ -75,14 +90,14 @@ 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); + var envelope = _crypto.EncryptBinary(_dek, 5, blob, 42, "callattachments.data", "88"); _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); + _crypto.DecryptBinary(_dek, envelope, 42, "callattachments.data", "88").Should().Equal(blob); } [Test] @@ -96,8 +111,8 @@ public void Binary_double_encrypt_is_refused_and_plain_blobs_are_not_enveloped() _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); + var envelope = _crypto.EncryptBinary(_dek, 1, blob, 42, "contacts.image", "c-1"); + var act = () => _crypto.EncryptBinary(_dek, 1, envelope, 42, "contacts.image", "c-1"); act.Should().Throw(); } @@ -110,10 +125,10 @@ public void Binary_encrypt_refuses_a_non_positive_key_version() RandomNumberGenerator.Fill(blob); blob[0] = 0x00; - var zero = () => _crypto.EncryptBinary(_dek, 0, blob, 42, "contacts.image", "c-1", 1); + var zero = () => _crypto.EncryptBinary(_dek, 0, blob, 42, "contacts.image", "c-1"); zero.Should().Throw(); - var negative = () => _crypto.EncryptBinary(_dek, -3, blob, 42, "contacts.image", "c-1", 1); + var negative = () => _crypto.EncryptBinary(_dek, -3, blob, 42, "contacts.image", "c-1"); negative.Should().Throw(); } @@ -124,9 +139,9 @@ public void Binary_aad_mismatch_fails_authentication() RandomNumberGenerator.Fill(blob); blob[0] = 0x00; - var envelope = _crypto.EncryptBinary(_dek, 1, blob, 42, "contacts.image", "c-1", 1); + var envelope = _crypto.EncryptBinary(_dek, 1, blob, 42, "contacts.image", "c-1"); - var act = () => _crypto.DecryptBinary(_dek, envelope, 43, "contacts.image", "c-1", 1); + var act = () => _crypto.DecryptBinary(_dek, envelope, 43, "contacts.image", "c-1"); act.Should().Throw(); } @@ -134,7 +149,7 @@ public void Binary_aad_mismatch_fails_authentication() public void Wrong_dek_size_is_rejected() { var shortKey = new byte[16]; - var act = () => _crypto.EncryptText(shortKey, 1, "x", 42, "calls.notes", "7", 1); + var act = () => _crypto.EncryptText(shortKey, 1, "x", 42, "calls.notes", "7"); act.Should().Throw(); } } diff --git a/Tests/Resgrid.Tests/Services/ProtectedOutboundGuardTests.cs b/Tests/Resgrid.Tests/Services/ProtectedOutboundGuardTests.cs new file mode 100644 index 000000000..232388ce8 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ProtectedOutboundGuardTests.cs @@ -0,0 +1,185 @@ +using System.Net.Mail; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Services; +using Resgrid.Providers.EmailProvider; +using Resgrid.Providers.NumberProvider; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// The queue-side outbound net (ADP plan 7.5). Worker output never passes through the HTTP + /// response filter, and it is the one direction that cannot be taken back: an email is + /// delivered, an SMS reaches a carrier, a push lands on a lock screen. These pin that an + /// envelope which slipped past its projection is scrubbed on the way out, and that the send is + /// never blocked — a degraded dispatch beats a missing one. + /// + [TestFixture] + public class ProtectedOutboundGuardTests : TestBase + { + private const string Envelope = "rgdp:1:2:c29tZS1jaXBoZXJ0ZXh0"; + + [Test] + public void An_envelope_in_text_is_replaced_with_the_placeholder() + { + var scrubbed = ProtectedOutboundGuard.Scrub($"Dispatch: {Envelope} at {Envelope}", out var count); + + count.Should().Be(2); + scrubbed.Should().NotContain("rgdp:"); + scrubbed.Should().Be($"Dispatch: {ProtectedDataEnvelope.RedactionValue} at {ProtectedDataEnvelope.RedactionValue}"); + } + + [Test] + public void Ordinary_text_is_left_exactly_as_it_was() + { + // Matching loosely on "rgdp:" would scrub a support email that merely discusses the + // envelope format, which would be its own kind of bug. + const string message = "Engine 1 responding to 26-45, staging at the corner."; + + ProtectedOutboundGuard.Scrub(message, out var count).Should().Be(message); + count.Should().Be(0); + + ProtectedOutboundGuard.Scrub("The rgdp: prefix marks a protected value.", out var prose) + .Should().Be("The rgdp: prefix marks a protected value."); + prose.Should().Be(0); + } + + [Test] + public void Null_and_empty_text_pass_through() + { + ProtectedOutboundGuard.Scrub(null, out var nullCount).Should().BeNull(); + nullCount.Should().Be(0); + + ProtectedOutboundGuard.Scrub(string.Empty, out var emptyCount).Should().BeEmpty(); + emptyCount.Should().Be(0); + } + + [Test] + public void A_push_title_and_subtitle_are_scrubbed_but_the_push_still_goes() + { + var inner = new RecordingPushService(); + var decorator = new ProtectedPushServiceDecorator(inner); + var message = new Resgrid.Model.Messages.StandardPushMessage + { + Title = Envelope, + SubTitle = $"Nature: {Envelope}", + DepartmentId = 7 + }; + + decorator.PushNotification(message, "user-1").Wait(); + + inner.Sent.Should().Be(1, "the notification must still reach the responder"); + message.Title.Should().Be(ProtectedDataEnvelope.RedactionValue); + message.SubTitle.Should().Be($"Nature: {ProtectedDataEnvelope.RedactionValue}"); + } + + [Test] + public void An_email_subject_and_body_are_scrubbed_but_the_email_still_sends() + { + var inner = new RecordingEmailSender(); + var decorator = new ProtectedEmailSenderDecorator(inner); + var mail = new MailMessage + { + Subject = $"Call {Envelope}", + Body = $"Nature of call: {Envelope}" + }; + mail.To.Add("someone@example.org"); + + decorator.SendEmail(mail).Wait(); + + inner.Sent.Should().Be(1); + mail.Subject.Should().NotContain("rgdp:"); + mail.Body.Should().NotContain("rgdp:"); + } + + [Test] + public void An_enveloped_attachment_is_dropped_rather_than_delivered_as_ciphertext() + { + var inner = new RecordingEmailSender(); + var decorator = new ProtectedEmailSenderDecorator(inner); + var mail = new MailMessage { Subject = "Report", Body = "Attached." }; + mail.To.Add("someone@example.org"); + + var payload = System.Text.Encoding.ASCII.GetBytes(ProtectedDataEnvelope.BinaryPrefix + "1:2:xyz"); + mail.Attachments.Add(new Attachment(new System.IO.MemoryStream(payload), "report.pdf")); + + decorator.SendEmail(mail).Wait(); + + mail.Attachments.Count.Should().Be(0, "there is nothing to redact inside a file"); + inner.Sent.Should().Be(1, "the message itself still goes"); + } + + [Test] + public void A_plain_attachment_is_left_alone() + { + var inner = new RecordingEmailSender(); + var decorator = new ProtectedEmailSenderDecorator(inner); + var mail = new MailMessage { Subject = "Report", Body = "Attached." }; + mail.To.Add("someone@example.org"); + mail.Attachments.Add(new Attachment( + new System.IO.MemoryStream(System.Text.Encoding.ASCII.GetBytes("%PDF-1.4 real report")), "report.pdf")); + + decorator.SendEmail(mail).Wait(); + + mail.Attachments.Count.Should().Be(1); + } + + [Test] + public void Every_outbound_channel_is_wrapped_in_the_container() + { + // The net is only a net if it is actually registered. This also proves the decorated + // graphs still resolve — the voice decorator depends on the projection service. + Resolve().Should().BeOfType(); + Resolve().Should().BeOfType(); + Resolve().Should().BeOfType(); + Resolve().Should().BeOfType(); + } + + private sealed class RecordingEmailSender : IEmailSender + { + public int Sent { get; private set; } + + public System.Threading.Tasks.Task SendEmail(MailMessage email) + { + Sent++; + return System.Threading.Tasks.Task.FromResult(true); + } + + public System.Threading.Tasks.Task Send(Email email) + { + Sent++; + return System.Threading.Tasks.Task.FromResult(true); + } + + public MailMessage CreateMailMessageFromEmail(Email email) => new MailMessage(); + } + + private sealed class RecordingPushService : IPushService + { + public int Sent { get; private set; } + + private System.Threading.Tasks.Task Record() + { + Sent++; + return System.Threading.Tasks.Task.FromResult(true); + } + + public System.Threading.Tasks.Task PushMessage(Resgrid.Model.Messages.StandardPushMessage message, string userId, UserProfile profile = null) => Record(); + public System.Threading.Tasks.Task PushCall(Resgrid.Model.Messages.StandardPushCall call, string userId, UserProfile profile = null, DepartmentCallPriority priority = null) => Record(); + public System.Threading.Tasks.Task Register(PushUri pushUri) => Record(); + public System.Threading.Tasks.Task UnRegister(PushUri pushUri) => Record(); + public void UnRegisterNotificationOnly(PushUri pushUri) { } + public System.Threading.Tasks.Task PushNotification(Resgrid.Model.Messages.StandardPushMessage message, string userId, UserProfile profile = null) => Record(); + public System.Threading.Tasks.Task PushICNotification(Resgrid.Model.Messages.StandardPushMessage message, string userId, UserProfile profile = null) => Record(); + public System.Threading.Tasks.Task RegisterUnit(PushUri pushUri) => Record(); + public System.Threading.Tasks.Task UnRegisterUnit(PushUri pushUri) => Record(); + public System.Threading.Tasks.Task PushChat(Resgrid.Model.Messages.StandardPushMessage message, string userId, UserProfile profile = null) => Record(); + public System.Threading.Tasks.Task PushCallUnit(Resgrid.Model.Messages.StandardPushCall call, int unitId, DepartmentCallPriority priority = null) => Record(); + public System.Threading.Tasks.Task PushChatMessage(Resgrid.Model.Messages.StandardPushMessage message, string userId, string eventCode, int unreadCount, bool includeIncidentCommandApp, UserProfile profile = null) => Record(); + public System.Threading.Tasks.Task PushChatMessageUnit(Resgrid.Model.Messages.StandardPushMessage message, int unitId, string eventCode, int unreadCount) => Record(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ProtectedProjectionServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedProjectionServiceTests.cs index 90ab5589e..faeb8265f 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedProjectionServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedProjectionServiceTests.cs @@ -187,6 +187,51 @@ public async Task Chat_platforms_are_always_generic_for_protected_departments() "third-party chat egress has no allow mode"); } + [Test] + public async Task Allow_protected_content_degrades_when_any_cataloged_field_is_enveloped() + { + _protection.Setup(x => x.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(true); + SetupEgress(push: Resgrid.Model.ProtectedDataEgressMode.AllowProtectedContent); + + // Name/NatureOfCall/Address are plaintext, so a fixed three-field check would wave this + // through — but Notes carries an envelope, and templates and TTS prompts read it. Every + // cataloged field must be checked or ciphertext reaches the carrier. + var call = new Resgrid.Model.Call + { + CallId = 1001, + DepartmentId = DeptId, + Number = "26-100", + Name = "Structure Fire", + NatureOfCall = "Smoke showing", + Notes = "rgdp:1:1:notes==" + }; + + var safe = await _service.BuildNotificationSafeCallAsync(DeptId, call, Resgrid.Model.ProtectedDataEgressChannel.Push); + + safe.Should().NotBeSameAs(call, "an enveloped cataloged field forces the sanitized clone"); + safe.NatureOfCall.Should().Be(ProtectedProjectionService.GenericDispatchText); + safe.Notes.Should().BeNull("the sanitized clone carries no user-authored content"); + } + + [Test] + public async Task Allow_protected_content_degrades_on_an_enveloped_contact_number() + { + _protection.Setup(x => x.IsProtectionEnforcedAsync(DeptId)).ReturnsAsync(true); + SetupEgress(push: Resgrid.Model.ProtectedDataEgressMode.AllowProtectedContent); + + var call = new Resgrid.Model.Call + { + CallId = 1001, + DepartmentId = DeptId, + Number = "26-100", + Name = "Structure Fire", + ContactNumber = "rgdp:1:1:contactnumber==" + }; + + (await _service.BuildNotificationSafeCallAsync(DeptId, call, Resgrid.Model.ProtectedDataEgressChannel.Push)) + .Should().NotBeSameAs(call); + } + [Test] public async Task Unknown_protection_or_egress_state_sanitizes_defensively() { diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index 723b2ae48..7cf46a9e9 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -56,7 +56,7 @@ public void SetUp() _brokerClient = new Mock(); - _service = new ProtectedReadService(_dataProtectionService.Object, _grantService, _brokerClient.Object); + _service = new ProtectedReadService(_dataProtectionService.Object, _grantService, _brokerClient.Object, new ProtectedFieldCatalog()); } private string IssueGrant(string userId = UserId, long epoch = Epoch) @@ -238,6 +238,52 @@ public void Child_accessor_maps_cover_every_catalog_v1_child_column() var contactNotesBinding = AdpTableBindings.V1.Single(b => b.TableName == "ContactNotes"); ProtectedReadService.ContactNoteFieldAccessors.Keys .Should().BeEquivalentTo(contactNotesBinding.Columns.Select(c => c.FieldId)); + + var unitStatesBinding = AdpTableBindings.V1.Single(b => b.TableName == "UnitStates"); + ProtectedReadService.UnitStateFieldAccessors.Keys + .Concat(ProtectedReadService.UnitStateCompanionAccessors.Keys) + .Should().BeEquivalentTo(unitStatesBinding.Columns.Select(c => c.FieldId)); + + var udfBinding = AdpTableBindings.V1.Single(b => b.TableName == "UdfFieldValues"); + ProtectedReadService.UdfFieldValueAccessors.Keys + .Should().BeEquivalentTo(udfBinding.Columns.Select(c => c.FieldId)); + + var logsBinding = AdpTableBindings.V1.Single(b => b.TableName == "Logs"); + ProtectedReadService.LogFieldAccessors.Keys + .Should().BeEquivalentTo(logsBinding.Columns.Select(c => c.FieldId)); + + var sensitiveBinding = AdpTableBindings.V1.Single(b => b.TableName == "DepartmentMemberSensitiveData"); + ProtectedReadService.MemberSensitiveDataAccessors.Keys + .Should().BeEquivalentTo(sensitiveBinding.Columns.Select(c => c.FieldId)); + + var emergencyBinding = AdpTableBindings.V1.Single(b => b.TableName == "DepartmentMemberEmergencyContacts"); + ProtectedReadService.MemberEmergencyContactAccessors.Keys + .Should().BeEquivalentTo(emergencyBinding.Columns.Select(c => c.FieldId)); + } + + /// + /// The per-table assertions above enumerate tables by hand, so a NEW binding added without a + /// read accessor map would slip past them silently — and a bound-but-unreadable column is + /// exactly how an envelope reaches a client as ciphertext. This pins the whole set: every + /// bound table either has accessor coverage or is on the explicit exclusion list below. + /// + [Test] + public void Every_bound_table_either_has_read_accessors_or_is_explicitly_excluded() + { + // Migrated (so their columns DO get enveloped) but not read back through this service. + // Anything added here needs its own safe-display or reveal path before it is read. + var withoutReadAccessors = new[] { "CallReferences" }; + + var covered = new[] + { + "Calls", "CallNotes", "CallAttachments", "Contacts", "ContactNotes", + "UnitStates", "UdfFieldValues", "Logs", "DepartmentMemberSensitiveData", + "DepartmentMemberEmergencyContacts", "PersonnelCertifications", "CallLogs" + }; + + AdpTableBindings.V1.Select(b => b.TableName) + .Should().BeEquivalentTo(covered.Concat(withoutReadAccessors), + "a newly bound table must get a read accessor map (or be deliberately listed as unread)"); } [Test] @@ -439,6 +485,451 @@ public async Task Redacted_sentinel_without_a_stored_row_is_never_encrypted() call.Name.Should().Be(ProtectedDataEnvelope.RedactionValue); } + [Test] + public async Task Fields_added_after_the_departments_pinned_catalog_version_are_not_encrypted() + { + SetupWriteEnforced(); + IReadOnlyList sentItems = null; + _brokerClient.Setup(x => x.EncryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>( + (d, g, r, items, ct) => sentItems = items) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult { FieldId = i.FieldId, RowKey = i.RowKey, Value = $"rgdp:1:1:{i.FieldId}==" }).ToList() + }); + + // The department is stamped at catalog v0 — it owns nothing yet, so no cataloged field + // may be encrypted under its (older) AAD until an upgrade sweeps it. + _dataProtectionService.Setup(x => x.GetPolicyByDepartmentIdAsync(DeptId, It.IsAny())) + .ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = DeptId, PolicyEpoch = Epoch, CatalogVersion = 0 }); + + var call = new Call { CallId = 17, DepartmentId = DeptId, Name = "Structure Fire" }; + + var result = await _service.PrepareCallWriteAsync(DeptId, call, null, null, UserId, workloadCaller: true); + + result.Success.Should().BeTrue("an unowned field is skipped, not a blocked write"); + result.Changed.Should().BeFalse(); + call.Name.Should().Be("Structure Fire", "the value stays plaintext for the upgrade sweep to pick up"); + sentItems.Should().BeNull("nothing reaches the broker"); + } + + [Test] + public async Task Fields_at_or_below_the_pinned_catalog_version_still_encrypt() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + _dataProtectionService.Setup(x => x.GetPolicyByDepartmentIdAsync(DeptId, It.IsAny())) + .ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = DeptId, PolicyEpoch = Epoch, CatalogVersion = 1 }); + + var call = new Call { CallId = 17, DepartmentId = DeptId, Name = "Structure Fire" }; + + var result = await _service.PrepareCallWriteAsync(DeptId, call, null, null, UserId, workloadCaller: true); + + result.Success.Should().BeTrue(); + result.Changed.Should().BeTrue(); + call.Name.Should().Be("rgdp:1:1:calls.name=="); + } + + /// + /// Stamps the department at the CURRENT catalog version — catalog-v2 fields are only written + /// for a department that has taken the upgrade (EncryptSlotsAsync skips fields the department + /// does not yet own), so the operational-family write tests must model an upgraded tenant. + /// + [Test] + public async Task Certifications_redact_without_a_grant_and_strip_the_enveloped_document() + { + var certification = new PersonnelCertification + { + PersonnelCertificationId = 9, + DepartmentId = DeptId, + Name = "rgdp:1:1:name==", + Number = "rgdp:1:1:number==", + Filetype = "application/pdf", + Data = System.Text.Encoding.ASCII.GetBytes("rgdpb:1:1:").Concat(new byte[] { 1, 2 }).ToArray() + }; + + var result = await _service.ResolveCertificationsForReadAsync(DeptId, new[] { certification }, null, UserId); + + result.IsProtected.Should().BeTrue(); + result.ProtectedReason.Should().Be("step_up_required"); + certification.Name.Should().Be(ProtectedDataEnvelope.RedactionValue); + certification.Number.Should().Be(ProtectedDataEnvelope.RedactionValue); + certification.Data.Should().BeNull("enveloped document bytes must never ride out through a serializer"); + + // Filetype is not cataloged, and the list view uses it to decide whether a document + // exists — concealing a file must not look like deleting it. + certification.Filetype.Should().Be("application/pdf"); + } + + [Test] + public async Task Certification_document_decrypts_only_when_a_caller_opts_into_the_payload() + { + _brokerClient.Setup(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult + { + FieldId = i.FieldId, + RowKey = i.RowKey, + Value = i.IsBinary ? Convert.ToBase64String(new byte[] { 7, 7 }) : "plain" + }).ToList() + }); + + var certification = new PersonnelCertification + { + PersonnelCertificationId = 9, + DepartmentId = DeptId, + Name = "rgdp:1:1:name==", + Data = System.Text.Encoding.ASCII.GetBytes("rgdpb:1:1:").Concat(new byte[] { 1, 2 }).ToArray() + }; + + await _service.ResolveCertificationsForReadAsync(DeptId, new[] { certification }, IssueGrant(), UserId, + includeData: true); + + certification.Name.Should().Be("plain"); + certification.Data.Should().Equal(new byte[] { 7, 7 }); + } + + [Test] + public async Task Certification_write_envelopes_the_text_fields_and_the_document() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + SetupCurrentCatalogVersion(); + + var certification = new PersonnelCertification + { + PersonnelCertificationId = 12, + DepartmentId = DeptId, + Name = "EMT-B", + Number = "A-4471", + IssuedBy = "State Board", + Data = new byte[] { 9, 9, 9 } + }; + + var result = await _service.PrepareCertificationWriteAsync(DeptId, certification, null, IssueGrant(), UserId, + workloadCaller: false); + + result.Success.Should().BeTrue(); + result.Changed.Should().BeTrue(); + certification.Name.Should().StartWith("rgdp:"); + certification.Number.Should().StartWith("rgdp:"); + certification.IssuedBy.Should().StartWith("rgdp:"); + System.Text.Encoding.ASCII.GetString(certification.Data).Should().StartWith("rgdpb:"); + certification.IsProtected.Should().BeTrue(); + } + + [Test] + public async Task Certification_write_restores_a_redacted_field_from_the_stored_row() + { + // An admin editing a member's certification without a grant posts back the placeholder. + // Encrypting the literal "REDACTED" would destroy the member's licence number. + SetupWriteEnforced(); + SetupEncryptEcho(); + SetupCurrentCatalogVersion(); + + var stored = new PersonnelCertification + { + PersonnelCertificationId = 12, + DepartmentId = DeptId, + Number = "rgdp:1:1:stored-number==", + Data = System.Text.Encoding.ASCII.GetBytes("rgdpb:1:1:").Concat(new byte[] { 4 }).ToArray() + }; + + var edited = new PersonnelCertification + { + PersonnelCertificationId = 12, + DepartmentId = DeptId, + Name = "EMT-P", + Number = ProtectedDataEnvelope.RedactionValue, + Data = null + }; + + var result = await _service.PrepareCertificationWriteAsync(DeptId, edited, stored, IssueGrant(), UserId, + workloadCaller: false); + + result.Success.Should().BeTrue(); + edited.Number.Should().Be("rgdp:1:1:stored-number==", "the sentinel restores the stored envelope"); + edited.Name.Should().StartWith("rgdp:", "a genuinely edited field is still encrypted"); + + // No new upload means keep the stored document, not delete it. + edited.Data.Should().Equal(stored.Data); + } + + [Test] + public async Task Certifications_are_skipped_by_a_department_pinned_below_catalog_v6() + { + // The department has not been swept for v6 yet, so its certification columns are still + // plaintext by design — encrypting them now would leave rows the sweep never migrated. + SetupWriteEnforced(); + SetupEncryptEcho(); + _dataProtectionService.Setup(x => x.GetPolicyByDepartmentIdAsync(DeptId, It.IsAny())) + .ReturnsAsync(new DepartmentDataProtectionPolicy + { + DepartmentId = DeptId, + PolicyEpoch = Epoch, + CatalogVersion = 5 + }); + + var certification = new PersonnelCertification + { + PersonnelCertificationId = 12, + DepartmentId = DeptId, + Name = "EMT-B" + }; + + var result = await _service.PrepareCertificationWriteAsync(DeptId, certification, null, IssueGrant(), UserId, + workloadCaller: false); + + result.Success.Should().BeTrue(); + certification.Name.Should().Be("EMT-B", "a field the department does not own yet stays plaintext"); + + // The row marker still goes on, and that is correct: it means "this row has protected + // values", not "this row is fully swept". The bulk sweep selects by department and pk + // cursor rather than by the marker, so the catalog upgrade still revisits this row and + // encrypts the v6 columns then. + certification.IsProtected.Should().BeTrue(); + } + + [Test] + public async Task Call_log_narratives_redact_without_a_grant() + { + // CallLogs is a different table and entity from the Log family — both call their text + // column Narrative, and only the Log family had a read path. These rows were being + // enveloped by the sweep and then rendered raw by the call export and the logs index. + var log = new CallLog + { + CallLogId = 4, + DepartmentId = DeptId, + CallId = 9, + Narrative = "rgdp:1:1:narrative==" + }; + + var result = await _service.ResolveCallLogsForReadAsync(DeptId, new[] { log }, null, UserId); + + result.IsProtected.Should().BeTrue(); + result.ProtectedReason.Should().Be("step_up_required"); + log.Narrative.Should().Be(ProtectedDataEnvelope.RedactionValue); + } + + [Test] + public async Task Call_log_write_envelopes_the_narrative() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + SetupCurrentCatalogVersion(); + + var log = new CallLog + { + CallLogId = 4, + DepartmentId = DeptId, + CallId = 9, + Narrative = "Engine 1 arrived on scene" + }; + + var result = await _service.PrepareCallLogWriteAsync(DeptId, log, IssueGrant(), UserId, + workloadCaller: false); + + result.Success.Should().BeTrue(); + result.Changed.Should().BeTrue(); + log.Narrative.Should().StartWith("rgdp:"); + } + + private void SetupCurrentCatalogVersion() + { + _dataProtectionService.Setup(x => x.GetPolicyByDepartmentIdAsync(DeptId, It.IsAny())) + .ReturnsAsync(new DepartmentDataProtectionPolicy + { + DepartmentId = DeptId, + PolicyEpoch = Epoch, + CatalogVersion = new ProtectedFieldCatalog().Version + }); + } + + [Test] + public async Task Unit_state_write_moves_coordinates_into_companion_envelopes_and_marks_the_row() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + SetupCurrentCatalogVersion(); + var state = new UnitState + { + UnitStateId = 55, + UnitId = 7, + Note = "Crew of 3, air packs on", + GeoLocationData = "39.19,-119.76", + Latitude = 39.19m, + Longitude = -119.76m + }; + + var result = await _service.PrepareUnitStateWriteAsync(DeptId, state, IssueGrant(), UserId, workloadCaller: false); + + result.Success.Should().BeTrue(); + state.IsProtected.Should().BeTrue(); + state.Note.Should().Be("rgdp:1:1:unitstates.note=="); + state.GeoLocationData.Should().Be("rgdp:1:1:unitstates.geolocationdata=="); + state.Latitude.Should().BeNull("the typed column nulls; the envelope companion carries the value"); + state.ProtectedLatitudeEnvelope.Should().Be("rgdp:1:1:unitstates.latitude=="); + state.Longitude.Should().BeNull(); + state.ProtectedLongitudeEnvelope.Should().Be("rgdp:1:1:unitstates.longitude=="); + } + + [Test] + public async Task Unit_state_read_reveals_text_and_restores_typed_coordinates() + { + var state = new UnitState + { + UnitStateId = 55, + UnitId = 7, + Note = "rgdp:1:1:note==", + ProtectedLatitudeEnvelope = "rgdp:1:1:lat==" + }; + _brokerClient.Setup(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult + { + FieldId = i.FieldId, + RowKey = i.RowKey, + Value = i.FieldId == "unitstates.latitude" ? "39.19" : "Crew of 3, air packs on" + }).ToList() + }); + + var result = await _service.ResolveUnitStatesForReadAsync(DeptId, new[] { state }, IssueGrant(), UserId); + + result.IsProtected.Should().BeTrue(); + state.Note.Should().Be("Crew of 3, air packs on"); + state.Latitude.Should().Be(39.19m, "the companion envelope restores the typed column on reveal"); + } + + [Test] + public async Task Unit_state_read_without_a_grant_redacts_and_leaves_coordinates_concealed() + { + var state = new UnitState + { + UnitStateId = 55, + UnitId = 7, + Note = "rgdp:1:1:note==", + ProtectedLatitudeEnvelope = "rgdp:1:1:lat==" + }; + + var result = await _service.ResolveUnitStatesForReadAsync(DeptId, new[] { state }, null, UserId); + + result.ProtectedReason.Should().Be("step_up_required"); + state.Note.Should().Be(ProtectedDataEnvelope.RedactionValue); + state.Latitude.Should().BeNull(); + result.RedactedFields.Should().Contain("unitstates.note"); + } + + [Test] + public async Task Udf_field_value_round_trips_through_the_protected_pipeline() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + SetupCurrentCatalogVersion(); + var value = new UdfFieldValue { UdfFieldValueId = "udf-value-1", UdfFieldId = "f1", Value = "Patient is diabetic" }; + + var write = await _service.PrepareUdfFieldValueWriteAsync(DeptId, value, IssueGrant(), UserId, workloadCaller: false); + + write.Success.Should().BeTrue(); + value.Value.Should().Be("rgdp:1:1:udffieldvalues.value=="); + + var read = await _service.ResolveUdfFieldValuesForReadAsync(DeptId, new[] { value }, null, UserId); + + read.ProtectedReason.Should().Be("step_up_required"); + value.Value.Should().Be(ProtectedDataEnvelope.RedactionValue, "no grant means the placeholder, never ciphertext"); + } + + [Test] + public async Task Incident_log_round_trips_through_the_protected_pipeline() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + SetupCurrentCatalogVersion(); + var log = new Log + { + LogId = 88, + DepartmentId = DeptId, + Narrative = "Patient extricated at 0412", + BodyLocation = "Second floor bedroom", + ContactNumber = "555-0100" + }; + + var write = await _service.PrepareLogWriteAsync(DeptId, log, IssueGrant(), UserId, workloadCaller: false); + + write.Success.Should().BeTrue(); + log.Narrative.Should().Be("rgdp:1:1:logs.narrative=="); + log.BodyLocation.Should().Be("rgdp:1:1:logs.bodylocation=="); + log.ContactNumber.Should().Be("rgdp:1:1:logs.contactnumber=="); + + var read = await _service.ResolveLogsForReadAsync(DeptId, new[] { log }, null, UserId); + + read.ProtectedReason.Should().Be("step_up_required"); + log.Narrative.Should().Be(ProtectedDataEnvelope.RedactionValue, "no grant means the placeholder, never ciphertext"); + read.RedactedFields.Should().Contain("logs.bodylocation"); + } + + [Test] + public async Task Member_addresses_are_protected_as_a_unit() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + SetupCurrentCatalogVersion(); + var data = new DepartmentMemberSensitiveData + { + DepartmentMemberSensitiveDataId = 12, + DepartmentId = DeptId, + UserId = "user-1", + HomeAddress1 = "12 Elm Street", + HomeCity = "Carson City", + HomeState = "NV", + HomePostalCode = "89701", + HomeCountry = "United States" + }; + + var result = await _service.PrepareMemberSensitiveDataWriteAsync(DeptId, data, IssueGrant(), UserId, workloadCaller: false); + + result.Success.Should().BeTrue(); + + // Every component, not just the street line: a city/state/postal left in the clear + // re-identifies a member in a small department. + data.HomeAddress1.Should().Be("rgdp:1:1:departmentmembersensitivedata.homeaddress1=="); + data.HomeCity.Should().Be("rgdp:1:1:departmentmembersensitivedata.homecity=="); + data.HomeState.Should().Be("rgdp:1:1:departmentmembersensitivedata.homestate=="); + data.HomePostalCode.Should().Be("rgdp:1:1:departmentmembersensitivedata.homepostalcode=="); + data.HomeCountry.Should().Be("rgdp:1:1:departmentmembersensitivedata.homecountry=="); + } + + [Test] + public async Task Member_address_read_without_a_grant_redacts_every_component() + { + var data = new DepartmentMemberSensitiveData + { + DepartmentMemberSensitiveDataId = 12, + DepartmentId = DeptId, + UserId = "user-1", + HomeAddress1 = "rgdp:1:1:street==", + HomeCity = "rgdp:1:1:city==", + MailingPostalCode = "rgdp:1:1:zip==" + }; + + var result = await _service.ResolveMemberSensitiveDataForReadAsync(DeptId, new[] { data }, null, UserId); + + result.ProtectedReason.Should().Be("step_up_required"); + data.HomeAddress1.Should().Be(ProtectedDataEnvelope.RedactionValue); + data.HomeCity.Should().Be(ProtectedDataEnvelope.RedactionValue); + data.MailingPostalCode.Should().Be(ProtectedDataEnvelope.RedactionValue); + } + [Test] public async Task Broker_fault_blocks_the_write_and_applies_nothing() { diff --git a/Tests/Resgrid.Tests/Services/UnitsServiceProtectedWriteTests.cs b/Tests/Resgrid.Tests/Services/UnitsServiceProtectedWriteTests.cs new file mode 100644 index 000000000..70e4a52ce --- /dev/null +++ b/Tests/Resgrid.Tests/Services/UnitsServiceProtectedWriteTests.cs @@ -0,0 +1,109 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Pins the catalog-v2 write safety net in UnitsService.SetUnitStateAsync: every caller — v4 API, + /// the apps, unit-tracking ingress, workers — goes through this one method, so a protected + /// department's unit-state note, geolocation and coordinates are enveloped here rather than at + /// each call site, and a blocked write throws instead of leaving plaintext at rest. + /// + [TestFixture] + public class UnitsServiceProtectedWriteTests + { + private const int DeptId = 10; + + private Mock _unitStatesRepo; + private Mock _protectedWriteService; + private UnitsService _service; + + [SetUp] + public void SetUp() + { + _unitStatesRepo = new Mock(); + _unitStatesRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((UnitState s, CancellationToken _, bool __) => s); + + _protectedWriteService = new Mock(); + _protectedWriteService.Setup(x => x.PrepareUnitStateWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + + _service = new UnitsService( + Mock.Of(), _unitStatesRepo.Object, Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), + new Lazy>(() => Mock.Of>()), + Mock.Of(), + new Lazy(() => Mock.Of()), + Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), + new Lazy(() => _protectedWriteService.Object)); + } + + private static UnitState BuildState() => new UnitState + { + UnitStateId = 55, + UnitId = 7, + State = 2, + Note = "Crew of 3", + Timestamp = DateTime.UtcNow + }; + + [Test] + public async Task Unprotected_department_saves_once_and_leaves_the_note_alone() + { + var state = BuildState(); + + var result = await _service.SetUnitStateAsync(state, DeptId); + + result.Note.Should().Be("Crew of 3"); + _protectedWriteService.Verify(x => x.PrepareUnitStateWriteAsync(DeptId, It.IsAny(), null, null, true, It.IsAny()), Times.Once); + _unitStatesRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Protected_department_repersists_the_enveloped_state() + { + _protectedWriteService.Setup(x => x.PrepareUnitStateWriteAsync(DeptId, It.IsAny(), null, null, true, It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed(isProtected: true, changed: true)); + + await _service.SetUnitStateAsync(BuildState(), DeptId); + + // Initial save plus the re-save that persists the envelopes. + _unitStatesRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public async Task Blocked_protected_write_throws_rather_than_leaving_plaintext() + { + _protectedWriteService.Setup(x => x.PrepareUnitStateWriteAsync(DeptId, It.IsAny(), null, null, true, It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Blocked("broker_unavailable")); + + Func act = async () => await _service.SetUnitStateAsync(BuildState(), DeptId); + + await act.Should().ThrowAsync().WithMessage("*broker_unavailable*"); + } + + [Test] + public async Task The_status_type_overload_is_covered_too() + { + await _service.SetUnitStateAsync(7, 2, DeptId); + + _protectedWriteService.Verify(x => x.PrepareUnitStateWriteAsync(DeptId, It.IsAny(), null, null, true, It.IsAny()), + Times.Once, "both SetUnitStateAsync overloads persist a unit state and both must be covered"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/WorkflowTemplateContextBuilderTests.cs b/Tests/Resgrid.Tests/Services/WorkflowTemplateContextBuilderTests.cs index 51a58fc8c..b239975ba 100644 --- a/Tests/Resgrid.Tests/Services/WorkflowTemplateContextBuilderTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkflowTemplateContextBuilderTests.cs @@ -25,6 +25,7 @@ public class with_the_context_builder : TestBase protected Mock DepartmentGroupsServiceMock; protected Mock PersonnelRolesServiceMock; protected Mock UnitsServiceMock; + protected Mock MemberSensitiveDataServiceMock; protected Department TestDepartment; protected UserProfile TestProfile; @@ -78,13 +79,24 @@ protected with_the_context_builder() .Setup(s => s.GetUnitByIdAsync(It.IsAny())) .ReturnsAsync((Unit)null); + // Department-scoped member data: no rows by default, so identification numbers render + // empty rather than falling back to the global profile column. + MemberSensitiveDataServiceMock = new Mock(); + MemberSensitiveDataServiceMock + .Setup(s => s.GetByDepartmentAndUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentMemberSensitiveData)null); + MemberSensitiveDataServiceMock + .Setup(s => s.GetResolvedForDepartmentAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new System.Collections.Generic.Dictionary()); + Sut = new WorkflowTemplateContextBuilder( DepartmentsServiceMock.Object, DepartmentSettingsServiceMock.Object, UserProfileServiceMock.Object, DepartmentGroupsServiceMock.Object, PersonnelRolesServiceMock.Object, - UnitsServiceMock.Object); + UnitsServiceMock.Object, + MemberSensitiveDataServiceMock.Object); } protected async Task BuildContext(WorkflowTriggerEventType eventType, object payload) diff --git a/Tests/Resgrid.Tests/Services/WorkflowTemplateRedactionTests.cs b/Tests/Resgrid.Tests/Services/WorkflowTemplateRedactionTests.cs new file mode 100644 index 000000000..099516a7a --- /dev/null +++ b/Tests/Resgrid.Tests/Services/WorkflowTemplateRedactionTests.cs @@ -0,0 +1,44 @@ +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; + +namespace Resgrid.Tests.Services +{ + /// + /// Workflow templates render into outbound email, SMS and webhooks and run UNATTENDED — no + /// Protected Data Grant can exist on that path (plan section 8). Every cataloged value must + /// therefore reach a template as the REDACTED placeholder, never as an rgdp envelope: a + /// workflow is a direct egress channel, so ciphertext here would leave the system. + /// + [TestFixture] + public class WorkflowTemplateRedactionTests + { + [Test] + public void Enveloped_call_values_degrade_to_the_placeholder() + { + // The builder maps through SafeDisplay; this pins the contract those mappings rely on + // for every cataloged call field a template can reference. + var enveloped = new[] + { + "rgdp:1:1:name==", "rgdp:1:1:nature==", "rgdp:1:1:notes==", "rgdp:1:1:address==", + "rgdp:1:1:geo==", "rgdp:1:1:contactname==", "rgdp:1:1:contactnumber==", + "rgdp:1:1:w3w==", "rgdp:1:1:formdata==", "rgdpb:1:1:binary==" + }; + + foreach (var value in enveloped) + ProtectedDataEnvelope.SafeDisplay(value).Should().Be(ProtectedDataEnvelope.RedactionValue, value); + } + + [Test] + public void Unprotected_department_values_reach_templates_unchanged() + { + // The redaction must not damage the ordinary case: an unprotected department's workflow + // output has to stay byte-for-byte what the author wrote. + ProtectedDataEnvelope.SafeDisplay("Structure fire, 2 story").Should().Be("Structure fire, 2 story"); + ProtectedDataEnvelope.SafeDisplay("39.19,-119.76").Should().Be("39.19,-119.76"); + ProtectedDataEnvelope.SafeDisplay(null).Should().BeNull(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/User/CertificationAuthorizationTests.cs b/Tests/Resgrid.Tests/Web/User/CertificationAuthorizationTests.cs new file mode 100644 index 000000000..470a56171 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/CertificationAuthorizationTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using FluentAssertions; +using NUnit.Framework; + +namespace Resgrid.Tests.Web.User +{ + /// + /// Certifications carry licence numbers and scanned documents — protected personnel data + /// (plan 5.1, catalog v6). Every action in the family checked only that the row belonged to the + /// caller's department, which proves tenancy, not that the caller may read THIS member's + /// record: any member holding Profile_View could list, download, edit or delete anyone's + /// certifications, and the reveal endpoint would decrypt them. + /// + /// A structural guard rather than a behavioural one on purpose. The failure mode is a NEW + /// action being added to the family without the subject check — a hole no test of the existing + /// actions would notice — so this asserts over the whole region instead. + /// + [TestFixture] + public class CertificationAuthorizationTests + { + /// Actions that resolve a subject from user input and must authorize it. + private static readonly string[] GuardedActions = + { + "Certifications", + "AddCertification", + "EditCertification", + "DeleteCertification", + "GetCertificationData", + "RevealCertifications" + }; + + /// The two entry points that establish "may this caller reach this member". + private static readonly string[] Guards = + { + "CanReachCertificationsForAsync", + "GetAuthorizedCertificationAsync" + }; + + private static string ControllerSource() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) + directory = directory.Parent; + + directory.Should().NotBeNull("the tests must be able to find the repository root"); + + var path = Path.Combine(directory!.FullName, "Web", "Resgrid.Web", "Areas", "User", + "Controllers", "ProfileController.cs"); + + File.Exists(path).Should().BeTrue($"expected the controller at {path}"); + return File.ReadAllText(path); + } + + /// + /// Crude but sufficient: take everything from an action's signature to the start of the next + /// action. Good enough to prove a guard call appears inside the body. + /// + private static IEnumerable<(string Action, string Body)> ActionBodies(string source) + { + // Every method declaration is a boundary, not just the actions — otherwise a private + // helper sitting between two actions is absorbed into the one above it and its own + // (legitimate) repository call reads as if the action made it. + var matches = Regex.Matches(source, + @"(?:public|private|protected|internal)\s+(?:static\s+)?(?:async\s+)?Task<[^>]+>\s+(?\w+)\s*\(", + RegexOptions.Compiled).Cast().ToList(); + + for (var i = 0; i < matches.Count; i++) + { + var start = matches[i].Index; + var end = i + 1 < matches.Count ? matches[i + 1].Index : source.Length; + yield return (matches[i].Groups["name"].Value, source.Substring(start, end - start)); + } + } + + [Test] + public void Every_certification_action_authorizes_the_subject_not_just_the_department() + { + var bodies = ActionBodies(ControllerSource()).ToList(); + + foreach (var action in GuardedActions) + { + var matching = bodies.Where(b => b.Action == action).ToList(); + matching.Should().NotBeEmpty($"{action} should still exist on ProfileController"); + + foreach (var body in matching) + { + Guards.Any(guard => body.Body.Contains(guard)) + .Should().BeTrue($"{action} reaches a member's certifications and must run the " + + "subject authorization check, not only the department comparison"); + } + } + } + + [Test] + public void No_certification_action_relies_on_a_bare_department_comparison() + { + // This is the exact shape that was wrong: proving the row is in the caller's tenant and + // treating that as permission to read it. + foreach (var body in ActionBodies(ControllerSource()).Where(b => GuardedActions.Contains(b.Action))) + { + body.Body.Should().NotContain("_certificationService.GetCertificationByIdAsync", + $"{body.Action} should load through GetAuthorizedCertificationAsync so the subject " + + "check cannot be skipped"); + } + } + } +} diff --git a/Tests/Resgrid.Tests/Workers/AdpMigrationLogicTests.cs b/Tests/Resgrid.Tests/Workers/AdpMigrationLogicTests.cs index 8415b0577..2dd29a41f 100644 --- a/Tests/Resgrid.Tests/Workers/AdpMigrationLogicTests.cs +++ b/Tests/Resgrid.Tests/Workers/AdpMigrationLogicTests.cs @@ -27,6 +27,7 @@ public class AdpMigrationLogicTests private Mock _engine; private Mock _departmentsService; private Mock _emailService; + private Mock _relocationService; private AdpMigrationLogic _logic; private bool _originalPaused; @@ -76,9 +77,15 @@ public void SetUp() _departmentsService.Setup(x => x.GetAllAdminsForDepartmentAsync(It.IsAny())) .ReturnsAsync(new List()); + // Relocation of legacy member-profile data runs as the first step of an encryption night; + // a no-op stub keeps these tests about the state machine. + _relocationService = new Mock(); + _relocationService.Setup(x => x.RelocateDepartmentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((int d, CancellationToken ct) => new MemberProfileRelocationResult { DepartmentId = d }); + _logic = new AdpMigrationLogic(_lockService.Object, _policyRepo.Object, _protectionService.Object, _keyService.Object, _engine.Object, new ProtectedFieldCatalog(), _departmentsService.Object, - _emailService.Object); + _emailService.Object, _relocationService.Object); } [TearDown] @@ -227,6 +234,27 @@ public async Task Full_enrollment_night_reaches_enabled_with_verified_engine() It.IsAny(), It.IsAny()), Times.Once); } + [Test] + public async Task Enrollment_relocates_legacy_member_profile_data_before_the_sweep() + { + // Otherwise the night encrypts an incomplete corpus and the member's identification + // number and address stay behind in the legacy global location, in the clear. + var order = new List(); + _relocationService.Setup(x => x.RelocateDepartmentAsync(DeptId, It.IsAny())) + .ReturnsAsync(new MemberProfileRelocationResult { DepartmentId = DeptId }) + .Callback(() => order.Add("relocate")); + _engine.Setup(x => x.RunEncryptionNightAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(AdpMigrationNightResult.Completed(100)) + .Callback(() => order.Add("encrypt")); + _engine.Setup(x => x.VerifyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + SetupPolicies(Policy(DepartmentDataProtectionState.EnrollmentQueued)); + + await _logic.Process(CancellationToken.None); + + order.Should().Equal("relocate", "encrypt"); + } + [Test] public async Task Window_close_checkpoints_and_releases_the_lock() { @@ -276,7 +304,7 @@ public async Task Unavailable_engine_skips_nights_and_never_fails_queued_departm var logic = new AdpMigrationLogic(_lockService.Object, _policyRepo.Object, _protectionService.Object, _keyService.Object, new NullDepartmentDataMigrationEngine(), new ProtectedFieldCatalog(), - _departmentsService.Object, _emailService.Object); + _departmentsService.Object, _emailService.Object, _relocationService.Object); var result = await logic.Process(CancellationToken.None); @@ -299,7 +327,7 @@ public async Task Unavailable_engine_still_runs_liveness_and_offboarding_flips() var logic = new AdpMigrationLogic(_lockService.Object, _policyRepo.Object, _protectionService.Object, _keyService.Object, new NullDepartmentDataMigrationEngine(), new ProtectedFieldCatalog(), - _departmentsService.Object, _emailService.Object); + _departmentsService.Object, _emailService.Object, _relocationService.Object); var result = await logic.Process(CancellationToken.None); @@ -432,5 +460,79 @@ public void Missing_or_bogus_time_zone_reads_as_closed() } #endregion - } + + #region Catalog upgrades + + [Test] + public async Task Enabled_department_behind_the_catalog_is_queued_for_an_upgrade() + { + var catalog = new ProtectedFieldCatalog(); + var policy = Policy(DepartmentDataProtectionState.Enabled); + policy.CatalogVersion = catalog.Version - 1; + SetupPolicies(policy); + + await _logic.Process(CancellationToken.None); + + _policyRepo.Verify(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.Enabled, + DepartmentDataProtectionState.Encrypting, + (int)DepartmentDataProtectionMigrationKind.CatalogUpgrade, It.IsAny(), It.IsAny()), + Times.Once, "a department behind the code's catalog is owed an upgrade sweep"); + } + + [Test] + public async Task Enabled_department_at_the_current_catalog_is_left_alone() + { + var policy = Policy(DepartmentDataProtectionState.Enabled); + policy.CatalogVersion = new ProtectedFieldCatalog().Version; + SetupPolicies(policy); + + await _logic.Process(CancellationToken.None); + + _policyRepo.Verify(x => x.TryTransitionStateAsync(It.IsAny(), DepartmentDataProtectionState.Enabled, + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never, "an up-to-date department must not be locked for a pointless sweep"); + } + + [Test] + public async Task Catalog_upgrade_night_sweeps_from_the_departments_pinned_version() + { + var catalog = new ProtectedFieldCatalog(); + var policy = Policy(DepartmentDataProtectionState.Encrypting, DepartmentDataProtectionMigrationKind.CatalogUpgrade); + policy.CatalogVersion = catalog.Version - 1; + SetupPolicies(policy); + + AdpMigrationNightContext captured = null; + _engine.Setup(x => x.RunEncryptionNightAsync(It.IsAny(), It.IsAny())) + .Callback((c, ct) => captured = c) + .ReturnsAsync(AdpMigrationNightResult.Completed(10)); + + await _logic.Process(CancellationToken.None); + + captured.Should().NotBeNull(); + captured.Kind.Should().Be(DepartmentDataProtectionMigrationKind.CatalogUpgrade); + captured.FromCatalogVersion.Should().Be(catalog.Version - 1, + "the pinned version stays on the policy until the upgrade verifies, which is what makes a resumed run sweep the same range"); + captured.CatalogVersion.Should().Be(catalog.Version); + } + + [Test] + public async Task Catalog_upgrade_keeps_its_kind_when_moving_to_verification() + { + var policy = Policy(DepartmentDataProtectionState.Encrypting, DepartmentDataProtectionMigrationKind.CatalogUpgrade); + policy.CatalogVersion = new ProtectedFieldCatalog().Version - 1; + SetupPolicies(policy); + + _engine.Setup(x => x.RunEncryptionNightAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(AdpMigrationNightResult.Completed(10)); + + await _logic.Process(CancellationToken.None); + + _policyRepo.Verify(x => x.TryTransitionStateAsync(DeptId, DepartmentDataProtectionState.Encrypting, + DepartmentDataProtectionState.Verifying, + (int)DepartmentDataProtectionMigrationKind.CatalogUpgrade, It.IsAny(), It.IsAny()), + Times.Once, "the run's kind must survive the verify transition, not be rewritten to Enrollment"); + } + + #endregion +} } diff --git a/Tests/Resgrid.Tests/Workers/MemberProfileRelocationLogicTests.cs b/Tests/Resgrid.Tests/Workers/MemberProfileRelocationLogicTests.cs new file mode 100644 index 000000000..41a2a7f97 --- /dev/null +++ b/Tests/Resgrid.Tests/Workers/MemberProfileRelocationLogicTests.cs @@ -0,0 +1,120 @@ +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.Services; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Tests.Workers +{ + /// + /// The sweep that drains the legacy member-profile relocation backlog (ADP plan 5.1) — the part + /// M0134 could not do in SQL because writing plaintext into an enrolled department's row would + /// poison it. + /// + [TestFixture] + public class MemberProfileRelocationLogicTests + { + private Mock _relocationService; + private Mock _protectionService; + private List _relocated; + private MemberProfileRelocationLogic _logic; + + [SetUp] + public void SetUp() + { + _relocated = new List(); + _relocationService = new Mock(); + _relocationService.Setup(x => x.RelocateDepartmentAsync(It.IsAny(), It.IsAny())) + .Returns((d, ct) => + { + _relocated.Add(d); + return Task.FromResult(new MemberProfileRelocationResult { DepartmentId = d, MembersExamined = 1 }); + }); + + _protectionService = new Mock(); + _protectionService.Setup(x => x.GetStateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(DepartmentDataProtectionState.Disabled); + + _logic = new MemberProfileRelocationLogic(_relocationService.Object, _protectionService.Object); + } + + private void SetupBacklog(params int[] departmentIds) => + _relocationService.Setup(x => x.GetDepartmentIdsWithOutstandingDataAsync()) + .ReturnsAsync(departmentIds); + + [Test] + public async Task An_empty_backlog_is_a_cheap_no_op() + { + SetupBacklog(); + + var result = await _logic.Process(CancellationToken.None); + + result.Item1.Should().BeTrue(); + result.Item2.Should().Contain("no outstanding"); + _relocationService.Verify(x => x.RelocateDepartmentAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Test] + public async Task Plaintext_and_fully_enrolled_departments_are_both_relocated() + { + // Disabled moves plaintext; Enabled moves through the ADP write path, which envelopes the + // value as it lands. Both are steady states, so neither can race a migration cursor. + SetupBacklog(1, 2); + _protectionService.Setup(x => x.GetStateAsync(2, It.IsAny())) + .ReturnsAsync(DepartmentDataProtectionState.Enabled); + + var result = await _logic.Process(CancellationToken.None); + + result.Item1.Should().BeTrue(); + _relocated.Should().Equal(1, 2); + } + + [Test] + public async Task A_department_mid_migration_is_left_to_its_encryption_night() + { + SetupBacklog(1); + _protectionService.Setup(x => x.GetStateAsync(1, It.IsAny())) + .ReturnsAsync(DepartmentDataProtectionState.Encrypting); + + var result = await _logic.Process(CancellationToken.None); + + _relocated.Should().BeEmpty(); + result.Item2.Should().Contain("deferred"); + } + + [Test] + public async Task A_large_backlog_is_capped_and_says_so() + { + // A silent cap would read as "the backlog is empty" when it is not. + var backlog = new List(); + for (var i = 1; i <= 30; i++) + backlog.Add(i); + SetupBacklog(backlog.ToArray()); + + var result = await _logic.Process(CancellationToken.None); + + _relocated.Should().HaveCount(25); + result.Item2.Should().Contain("5 department(s) deferred"); + } + + [Test] + public async Task A_failing_department_does_not_fail_the_sweep() + { + SetupBacklog(1, 2); + _relocationService.Setup(x => x.RelocateDepartmentAsync(1, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + + var result = await _logic.Process(CancellationToken.None); + + // The pass reports failure so the job surfaces it, and the untouched departments are + // picked up on the next run — nothing was marked, so nothing was lost. + result.Item1.Should().BeFalse(); + } + } +} diff --git a/Web/Resgrid.Web.Broker/Services/AdpMigrationSweepService.cs b/Web/Resgrid.Web.Broker/Services/AdpMigrationSweepService.cs index 7c30f54c8..3e9da241a 100644 --- a/Web/Resgrid.Web.Broker/Services/AdpMigrationSweepService.cs +++ b/Web/Resgrid.Web.Broker/Services/AdpMigrationSweepService.cs @@ -54,7 +54,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) 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}"); diff --git a/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs b/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs index a1fd0cc67..58ebc497f 100644 --- a/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs +++ b/Web/Resgrid.Web.Broker/Services/BrokerOperationService.cs @@ -166,7 +166,7 @@ private async Task DecryptItemsAsync(BrokerFieldOperationRequest request, IDepar try { itemResult.Value = Convert.ToBase64String(_cryptoService.DecryptBinary(binaryDek, blob, - request.DepartmentId, item.FieldId, item.RowKey, item.CatalogVersion)); + request.DepartmentId, item.FieldId, item.RowKey)); } catch (Exception ex) when (ex is CryptographicException || ex is FormatException || ex is ArgumentException) { @@ -197,7 +197,7 @@ private async Task DecryptItemsAsync(BrokerFieldOperationRequest request, IDepar try { itemResult.Value = _cryptoService.DecryptText(dek, item.Value, request.DepartmentId, - item.FieldId, item.RowKey, item.CatalogVersion); + item.FieldId, item.RowKey); } catch (Exception ex) when (ex is CryptographicException || ex is FormatException || ex is ArgumentException) { @@ -262,7 +262,7 @@ private async Task EncryptItemsAsync(BrokerFieldOperationRequest request, IDepar try { itemResult.Value = Convert.ToBase64String(_cryptoService.EncryptBinary(binaryDek, activeKey.Version, - plaintextBytes, request.DepartmentId, item.FieldId, item.RowKey, item.CatalogVersion)); + plaintextBytes, request.DepartmentId, item.FieldId, item.RowKey)); } catch (Exception ex) when (ex is CryptographicException || ex is ArgumentException || ex is InvalidOperationException) { @@ -290,7 +290,7 @@ private async Task EncryptItemsAsync(BrokerFieldOperationRequest request, IDepar try { itemResult.Value = _cryptoService.EncryptText(dek, activeKey.Version, item.Value, - request.DepartmentId, item.FieldId, item.RowKey, item.CatalogVersion); + request.DepartmentId, item.FieldId, item.RowKey); } catch (Exception ex) when (ex is CryptographicException || ex is ArgumentException || ex is InvalidOperationException) { diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs index 4c660854e..14882530b 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallFilesController.cs @@ -307,7 +307,7 @@ public async Task> SaveCallFile(SaveCallFileInp { Resgrid.Framework.Logging.LogError($"ADP protected write failed AFTER insert for call attachment {saved.CallAttachmentId} in department {call.DepartmentId} ({protectedWrite.Reason}); transient plaintext row pending re-encryption."); return Problem(type: protectedWrite.Reason, - title: "Protected storage is temporarily unavailable; the change was not saved.", + title: "The attachment was saved but could not be protected; protected storage is temporarily unavailable. Do not resubmit — the attachment will be encrypted automatically.", statusCode: StatusCodes.Status503ServiceUnavailable); } if (protectedWrite.IsProtected) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallNotesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallNotesController.cs index 5d2da8e3f..4f63cb1fc 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallNotesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallNotesController.cs @@ -162,7 +162,7 @@ public async Task> SaveCallNote(SaveCallNoteInp { Resgrid.Framework.Logging.LogError($"ADP protected write failed AFTER insert for call note {saved.CallNoteId} in department {DepartmentId} ({protectedWrite.Reason}); transient plaintext row pending re-encryption."); return Problem(type: protectedWrite.Reason, - title: "Protected storage is temporarily unavailable; the change was not saved.", + title: "The note was saved but could not be protected; protected storage is temporarily unavailable. Do not resubmit — the note will be encrypted automatically.", statusCode: StatusCodes.Status503ServiceUnavailable); } if (protectedWrite.IsProtected) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs index d1169ed64..dd1233ce4 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/MappingController.cs @@ -11,6 +11,7 @@ using Resgrid.Model; using Resgrid.Model.Providers; using System; +using System.Globalization; using Resgrid.Web.Services.Models.v4.Mapping; using Resgrid.Web.Services.Models.v4.Roles; using GeoJSON.Net; @@ -47,6 +48,8 @@ public class MappingController : V4AuthenticatedApiControllerbase private readonly Model.Services.IAuthorizationService _authorizationService; private readonly IIndoorMapService _indoorMapService; private readonly ICustomMapService _customMapService; + private readonly IProtectedReadService _protectedReadService; + private readonly IDepartmentDataProtectionService _dataProtectionService; public MappingController( IUsersService usersService, @@ -64,7 +67,9 @@ public MappingController( IMappingService mappingService, Model.Services.IAuthorizationService authorizationService, IIndoorMapService indoorMapService, - ICustomMapService customMapService + ICustomMapService customMapService, + IProtectedReadService protectedReadService, + IDepartmentDataProtectionService dataProtectionService ) { _usersService = usersService; @@ -83,7 +88,22 @@ ICustomMapService customMapService _authorizationService = authorizationService; _indoorMapService = indoorMapService; _customMapService = customMapService; + _protectedReadService = protectedReadService; + _dataProtectionService = dataProtectionService; } + + /// + /// True when the caller authenticated as the BigBoard client application. BigBoard is an + /// unattended display: it can never hold a grant, so it is stepped down structurally + /// (plan section 7.3) and egress policy can never relax that. + /// + private bool IsBigBoardSession => + string.Equals(User?.FindFirst(Resgrid.Model.Security.SessionClaimTypes.ClientApp)?.Value, + ((int)UserSessionClientApplication.BigBoard).ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal); + + /// The generic label a protected call carries on a map that cannot reveal it. + private const string ProtectedMapLabel = "Protected incident — open Resgrid to view details."; #endregion Members and Constructors /// @@ -256,15 +276,39 @@ public async Task> GetMapDataAndMarkers() if (calls != null && calls.Any()) { + // ADP (plan sections 7.1, 7.3): map markers are built from cataloged call fields, so + // they run through the protected-read pipeline first. With a valid grant the labels + // are plaintext; without one every cataloged value is the REDACTED placeholder and + // the marker degrades to a generic label with NO location — a map payload must never + // carry ciphertext, a protected label, or an exact protected position. BigBoard is + // stepped down unconditionally: it cannot hold a grant. + var mapCallsAreProtected = await _dataProtectionService.IsProtectionEnforcedAsync(DepartmentId); + if (mapCallsAreProtected) + { + var mapGrantToken = IsBigBoardSession + ? null + : Request.Headers[Resgrid.Web.Services.Controllers.v4.DataProtectionController.GrantHeader].ToString(); + + await _protectedReadService.ResolveForReadAsync(DepartmentId, calls.ToList(), mapGrantToken, UserId); + } + foreach (var call in calls) { + var callIsRedacted = mapCallsAreProtected && + (call.Name == ProtectedDataEnvelope.RedactionValue || + ProtectedDataEnvelope.HasEnvelopePrefix(call.Name)); + MapMakerInfoData info = new MapMakerInfoData(); info.ImagePath = "Call"; info.Id = $"c{call.CallId}"; - info.Title = call.Name; - info.InfoWindowContent = call.NatureOfCall; + info.Title = callIsRedacted ? ProtectedMapLabel : ProtectedDataEnvelope.SafeDisplay(call.Name); + info.InfoWindowContent = callIsRedacted ? null : ProtectedDataEnvelope.SafeDisplay(call.NatureOfCall); info.Type = 0; + // A redacted call contributes no marker at all: its position is itself protected. + if (callIsRedacted) + continue; + try { if (callTypes != null && callTypes.Count > 0 && !String.IsNullOrWhiteSpace(call.Type)) @@ -278,7 +322,9 @@ public async Task> GetMapDataAndMarkers() // info.ImagePath = ((MapIconTypes)type.MapIconType.Value).ToString(); //} - if (!String.IsNullOrEmpty(call.GeoLocationData) && call.GeoLocationData.Length > 1) + if (!String.IsNullOrEmpty(call.GeoLocationData) && call.GeoLocationData.Length > 1 && + call.GeoLocationData != ProtectedDataEnvelope.RedactionValue && + !ProtectedDataEnvelope.HasEnvelopePrefix(call.GeoLocationData)) { try { @@ -289,7 +335,9 @@ public async Task> GetMapDataAndMarkers() } catch { } } - else if (!String.IsNullOrEmpty(call.Address)) + else if (!String.IsNullOrEmpty(call.Address) && + call.Address != ProtectedDataEnvelope.RedactionValue && + !ProtectedDataEnvelope.HasEnvelopePrefix(call.Address)) { string coordinates = await _geoLocationProvider.GetLatLonFromAddress(call.Address); if (!String.IsNullOrEmpty(coordinates)) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelController.cs b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelController.cs index d0be723f3..95891682c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelController.cs @@ -34,6 +34,7 @@ public class PersonnelController : V4AuthenticatedApiControllerbase private readonly IActionLogsService _actionLogsService; private readonly IDepartmentsService _departmentsService; private readonly IUserProfileService _userProfileService; + private readonly IDepartmentMemberSensitiveDataService _memberSensitiveDataService; private readonly IUserStateService _userStateService; private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IPersonnelRolesService _personnelRolesService; @@ -47,6 +48,7 @@ public PersonnelController( IActionLogsService actionLogsService, IDepartmentsService departmentsService, IUserProfileService userProfileService, + IDepartmentMemberSensitiveDataService memberSensitiveDataService, IUserStateService userStateService, IDepartmentGroupsService departmentGroupsService, IPersonnelRolesService personnelRolesService, @@ -60,6 +62,7 @@ IUserDefinedFieldsService userDefinedFieldsService _actionLogsService = actionLogsService; _departmentsService = departmentsService; _userProfileService = userProfileService; + _memberSensitiveDataService = memberSensitiveDataService; _userStateService = userStateService; _departmentGroupsService = departmentGroupsService; _personnelRolesService = personnelRolesService; @@ -109,6 +112,7 @@ public async Task> GetPersonnelInfo(string use } var profile = await _userProfileService.GetProfileByUserIdAsync(user.UserId); + await ApplyMemberIdentificationNumberAsync(profile); var group = await _departmentGroupsService.GetGroupForUserAsync(user.UserId, DepartmentId); var roles = await _personnelRolesService.GetRolesForUserAsync(user.UserId, DepartmentId); var action = await _actionLogsService.GetLastActionLogForUserAsync(user.UserId, DepartmentId); @@ -166,6 +170,12 @@ public async Task> GetAllPersonnelInfos var canViewPII = await _authorizationService.CanUserViewPIIAsync(UserId, DepartmentId); var departmentMembers = await _departmentsService.GetAllMembersForDepartmentAsync(DepartmentId); + // Department-scoped identification numbers, resolved ONCE for the whole roster (plan 5.1). + // Doing this per member would read every DepartmentMemberSensitiveData row in the + // department and run a protected resolution on each pass of the loop. + var sensitiveByUser = await _memberSensitiveDataService.GetResolvedForDepartmentAsync(DepartmentId, + Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId); + foreach (var u in users) { if (!await _authorizationService.CanUserViewPersonViaMatrixAsync(u.UserId, UserId, DepartmentId)) @@ -196,6 +206,16 @@ public async Task> GetAllPersonnelInfos var roles = await _personnelRolesService.GetRolesForUserAsync(u.UserId, DepartmentId); var profile = await _userProfileService.GetProfileByUserIdAsync(u.UserId); + // Stamped from the map fetched once above. Calling the single-profile helper here + // would reload and re-resolve every DepartmentMemberSensitiveData row in the + // department on each iteration — one full-table read and one protected resolution + // per member. + if (profile != null) + profile.IdentificationNumber = profile.UserId != null && + sensitiveByUser.TryGetValue(profile.UserId, out var sensitive) + ? sensitive.IdentificationNumber + : null; + var s = await ConvertPersonnelInfo(u, department, profile, group, roles, log, state, canViewPII); if (log != null) @@ -624,5 +644,21 @@ private string GetTextValue(string filter, List filters) { return filters.Where(x => x.Id == filter).Select(y => y.Name).FirstOrDefault(); } + + /// + /// Replaces the profile's legacy global identification number with the DEPARTMENT-SCOPED one + /// (ADP plan 5.1): a badge number is department-issued, so the value a caller sees must be + /// the one THIS department holds. Resolved through the protected pipeline — plaintext with a + /// valid grant, the REDACTED placeholder without one. + /// + private async Task ApplyMemberIdentificationNumberAsync(UserProfile profile) + { + if (profile == null) + return; + + await _memberSensitiveDataService.ApplyIdentificationNumbersAsync(DepartmentId, new[] { profile }, + Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId); + } + } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RoutesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RoutesController.cs index f3879fc38..bfbd8726c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RoutesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RoutesController.cs @@ -23,11 +23,14 @@ public class RoutesController : V4AuthenticatedApiControllerbase { private readonly IRouteService _routeService; private readonly IContactsService _contactsService; + private readonly IProtectedReadService _protectedReadService; - public RoutesController(IRouteService routeService, IContactsService contactsService) + public RoutesController(IRouteService routeService, IContactsService contactsService, + IProtectedReadService protectedReadService) { _routeService = routeService; _contactsService = contactsService; + _protectedReadService = protectedReadService; } /// @@ -675,6 +678,12 @@ public async Task> GetStopContact(string rout if (contact == null || contact.DepartmentId != DepartmentId) return NotFound(); + // A route stop's contact carries names, phone numbers, email, coordinates and geofence — + // twelve cataloged fields. Without this the endpoint serializes rgdp ciphertext for a + // protected department (plan 7.1). + await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, new List { contact }, + Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId); + var result = new GetStopContactResult { Data = MapContactToResult(contact, stop.RouteStopId, stop.Name) @@ -701,6 +710,7 @@ public async Task> GetRouteContacts(string var result = new GetRouteContactsResult(); var seenContactIds = new HashSet(); + var resolved = new List<(Contact Contact, string RouteStopId, string StopName)>(); foreach (var stop in stops.Where(s => !string.IsNullOrWhiteSpace(s.ContactId))) { if (!seenContactIds.Add(stop.ContactId)) @@ -708,9 +718,17 @@ public async Task> GetRouteContacts(string var contact = await _contactsService.GetContactByIdAsync(stop.ContactId); if (contact != null && contact.DepartmentId == DepartmentId) - result.Data.Add(MapContactToResult(contact, stop.RouteStopId, stop.Name)); + resolved.Add((contact, stop.RouteStopId, stop.Name)); } + // One batched resolution for the whole route rather than one broker round trip per stop. + await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, + resolved.Select(r => r.Contact).ToList(), + Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId); + + foreach (var entry in resolved) + result.Data.Add(MapContactToResult(entry.Contact, entry.RouteStopId, entry.StopName)); + result.PageSize = result.Data.Count; ResponseHelper.PopulateV4ResponseData(result); return Ok(result); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs index 93bd8ea0c..d86b6749f 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UnitsController.cs @@ -33,10 +33,12 @@ public class UnitsController : V4AuthenticatedApiControllerbaseSystemAuth private readonly ICustomStateService _customStateService; private readonly IDepartmentsService _departmentsService; private readonly IUserDefinedFieldsService _userDefinedFieldsService; + private readonly IProtectedReadService _protectedReadService; public UnitsController(IUnitsService unitsService, IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, Model.Services.IAuthorizationService authorizationService, - IDepartmentsService departmentsService, IUserDefinedFieldsService userDefinedFieldsService) + IDepartmentsService departmentsService, IUserDefinedFieldsService userDefinedFieldsService, + IProtectedReadService protectedReadService) { _unitsService = unitsService; _departmentGroupsService = departmentGroupsService; @@ -44,6 +46,25 @@ public UnitsController(IUnitsService unitsService, IDepartmentGroupsService depa _authorizationService = authorizationService; _departmentsService = departmentsService; _userDefinedFieldsService = userDefinedFieldsService; + _protectedReadService = protectedReadService; + } + + /// The caller's Protected Data Grant, when presented (plan section 3.1 step 6). + private string ProtectedGrantToken => Request.Headers[DataProtectionController.GrantHeader].ToString(); + + /// + /// Resolves the catalog-v2 operational fields these endpoints return — unit-state note and + /// position, plus UDF values — through the attended read pipeline. With a valid grant the + /// values come back as plaintext; without one every enveloped value becomes the exact + /// REDACTED placeholder. Either way the DTO converters never see ciphertext. + /// + private async Task ResolveOperationalReadsAsync(IReadOnlyList states, IReadOnlyList udfValues) + { + if (states != null && states.Count > 0) + await _protectedReadService.ResolveUnitStatesForReadAsync(DepartmentId, states, ProtectedGrantToken, UserId); + + if (udfValues != null && udfValues.Count > 0) + await _protectedReadService.ResolveUdfFieldValuesForReadAsync(DepartmentId, udfValues, ProtectedGrantToken, UserId); } #endregion Members and Constructors @@ -73,6 +94,8 @@ public async Task> GetAllUnits() .GroupBy(v => v.EntityId) .ToDictionary(g => g.Key, g => g.ToList()); + await ResolveOperationalReadsAsync(unitStatuses?.ToList(), allUdfValues?.ToList()); + foreach (var unit in units) { if (!await _authorizationService.CanUserViewUnitViaMatrixAsync(unit.UnitId, UserId, DepartmentId)) @@ -310,7 +333,11 @@ public static UnitResultData ConvertUnitsData(Model.Unit unit, UnitState state, data.CurrentStatusId = state.State.ToString(); data.CurrentStatusTimestamp = state.Timestamp.TimeConverter(new Department() { TimeZone = timeZone }); - data.Note = state.Note; + + // Resolved upstream by ResolveOperationalReadsAsync: plaintext with a valid grant, + // the REDACTED placeholder without one. SafeDisplay stays as a belt-and-braces guard + // for any caller that reaches the converter without resolving first. + data.Note = ProtectedDataEnvelope.SafeDisplay(state.Note); if (state.DestinationId.HasValue) data.CurrentDestinationId = state.DestinationId.Value.ToString(); @@ -360,7 +387,7 @@ public static UnitsInfoResultData ConvertUnitsInfoResultData(Model.Unit unit, Un data.CurrentStatusTimestamp = state.Timestamp.TimeConverter(new Department() { TimeZone = timeZone }); data.CurrentStatusTimestampUtc = state.Timestamp; - data.Note = state.Note; + data.Note = ProtectedDataEnvelope.SafeDisplay(state.Note); if (state.DestinationId.HasValue) data.CurrentDestinationId = state.DestinationId.Value.ToString(); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UserDefinedFieldsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UserDefinedFieldsController.cs index e94ada091..e158e0293 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UserDefinedFieldsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UserDefinedFieldsController.cs @@ -31,15 +31,18 @@ public class UserDefinedFieldsController : V4AuthenticatedApiControllerbase private readonly IUserDefinedFieldsService _udfService; private readonly IUdfRenderingService _renderingService; private readonly IEventAggregator _eventAggregator; + private readonly IProtectedReadService _protectedReadService; public UserDefinedFieldsController( IUserDefinedFieldsService udfService, IUdfRenderingService renderingService, - IEventAggregator eventAggregator) + IEventAggregator eventAggregator, + IProtectedReadService protectedReadService) { _udfService = udfService; _renderingService = renderingService; _eventAggregator = eventAggregator; + _protectedReadService = protectedReadService; } /// Gets the active UDF definition and fields for the given entity type, filtered by the caller's role. @@ -132,6 +135,12 @@ public async Task> GetFieldValues(int entityT var values = await _udfService.GetFieldValuesForEntityAsync(DepartmentId, entityType, entityId); + // udffieldvalues.value is cataloged (catalog v2). Submitted UDF values are free text and + // default to sensitive in a protected department (plan 5.2), so they resolve with a grant + // and read as the placeholder without one — never as ciphertext. + await _protectedReadService.ResolveUdfFieldValuesForReadAsync(DepartmentId, values, + Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId, cancellationToken); + return Ok(new UdfFieldValuesResult { Data = values diff --git a/Web/Resgrid.Web.Services/Filters/ProtectedDataEgressFilter.cs b/Web/Resgrid.Web.Services/Filters/ProtectedDataEgressFilter.cs new file mode 100644 index 000000000..f15667c0f --- /dev/null +++ b/Web/Resgrid.Web.Services/Filters/ProtectedDataEgressFilter.cs @@ -0,0 +1,107 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection; +using Resgrid.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Services.Filters +{ + /// + /// API twin of the MVC response-boundary net (plan section 7.5). For a department whose + /// protection is enforced, the outgoing result object is walked and any value still carrying an + /// rgdp/rgdpb envelope is replaced with the REDACTED placeholder before serialization. + /// + /// The v4 surface is where this matters most: a result DTO is mapped field by field from an + /// entity, so forgetting the resolve call ships ciphertext to every mobile client. Two of the + /// four leaks found by hand were exactly this shape. + /// + /// Redacts AND logs — a silent redaction would hide the missing resolve call, and the log line + /// with the member path is what makes the missed surface findable. + /// + public sealed class ProtectedDataEgressFilter : IAsyncResultFilter + { + public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) + { + if (!DataProtectionConfig.EgressScanEnabled) + { + await next(); + return; + } + + var departmentClaim = context.HttpContext.User?.FindFirst(ClaimTypes.PrimaryGroupSid)?.Value; + if (!int.TryParse(departmentClaim, out var departmentId) || departmentId <= 0) + { + await next(); + return; + } + + bool enforced; + try + { + var protectionService = context.HttpContext.RequestServices.GetService(); + enforced = protectionService != null && await protectionService.IsProtectionEnforcedAsync(departmentId); + } + catch (Exception ex) + { + // Fails OPEN: this sits behind the real resolve calls, and a protection-state fault + // must not take the API down. + Logging.LogException(ex, $"ProtectedDataEgressFilter (api) could not read protection state for department {departmentId}; skipping the scan"); + await next(); + return; + } + + if (!enforced) + { + await next(); + return; + } + + Scan(context, departmentId); + await next(); + } + + private static void Scan(ResultExecutingContext context, int departmentId) + { + try + { + var action = (context.ActionDescriptor as Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor) + ?.DisplayName ?? context.HttpContext.Request.Path.ToString(); + + // A file endpoint that never resolved its payload would hand the caller ciphertext + // bytes as their document. Nothing to redact in a file — refuse it. + if (context.Result is FileContentResult file && + ProtectedEgressScanner.HasBinaryEnvelopePrefix(file.FileContents)) + { + Logging.LogError($"ADP egress net: {action} tried to serve an ENCRYPTED file to department {departmentId}; refused. The endpoint is missing its protected read (includeData)."); + context.Result = new NotFoundResult(); + return; + } + + var payload = context.Result switch + { + ObjectResult objectResult => objectResult.Value, + JsonResult json => json.Value, + _ => null + }; + + var result = ProtectedEgressScanner.Sanitize(payload, maxNodes: DataProtectionConfig.EgressScanMaxNodes); + if (!result.FoundAnything) + return; + + Logging.LogError($"ADP egress net caught unresolved protected data in {action} for department {departmentId}: " + + $"{result.Redacted} redacted, {result.Unfixable} unfixable{(result.Truncated ? ", scan truncated" : "")}. " + + $"Paths: {string.Join(", ", result.Paths.Take(25))}"); + } + catch (Exception ex) + { + Logging.LogException(ex, "ProtectedDataEgressFilter (api) failed while scanning the result"); + } + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunReportResult.cs b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunReportResult.cs index 9c7ebfa12..9f7f08d35 100644 --- a/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunReportResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/CommunicationTests/GetTestRunReportResult.cs @@ -54,7 +54,7 @@ public class CommunicationTestResultData /// /// Display name for as the department had it configured at run time, - /// or the raw level when it is no longer configured. Empty when no level was recorded. + /// or the raw level when it is no longer configured. "-" when no level was recorded. /// public string StaffingLevelText { get; set; } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 6b07229f8..4eb21c92d 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1962,6 +1962,16 @@ Mapping operations + + + True when the caller authenticated as the BigBoard client application. BigBoard is an + unattended display: it can never hold a grant, so it is stepped down structurally + (plan section 7.3) and egress policy can never relax that. + + + + The generic label a protected call carries on a map that cannot reveal it. + Data to center the map and it's default location plus marker information for displaying makers on the map. @@ -2250,6 +2260,14 @@ GetPersonnelFilterOptionsResult with information pertaining to each filter option + + + Replaces the profile's legacy global identification number with the DEPARTMENT-SCOPED one + (ADP plan 5.1): a badge number is department-issued, so the value a caller sees must be + the one THIS department holds. Resolved through the protected pipeline — plaintext with a + valid grant, the REDACTED placeholder without one. + + Operations involving the location of a person setting the real-time location or getting the latest location for people. @@ -2898,6 +2916,17 @@ Information regarding Units + + The caller's Protected Data Grant, when presented (plan section 3.1 step 6). + + + + Resolves the catalog-v2 operational fields these endpoints return — unit-state note and + position, plus UDF values — through the attended read pipeline. With a valid grant the + values come back as plaintext; without one every enveloped value becomes the exact + REDACTED placeholder. Either way the DTO converters never see ciphertext. + + Gets all the Units for a Department @@ -5479,6 +5508,20 @@ worker separately refuses to proceed when it cannot verify its own lock. + + + API twin of the MVC response-boundary net (plan section 7.5). For a department whose + protection is enforced, the outgoing result object is walked and any value still carrying an + rgdp/rgdpb envelope is replaced with the REDACTED placeholder before serialization. + + The v4 surface is where this matters most: a result DTO is mapped field by field from an + entity, so forgetting the resolve call ships ciphertext to every mobile client. Two of the + four leaks found by hand were exactly this shape. + + Redacts AND logs — a silent redaction would hide the missing resolve call, and the log line + with the member path is what makes the missed surface findable. + + Per-endpoint, incident-scoped capability gate (§3.11). Layered ON TOP of the broad @@ -9123,7 +9166,7 @@ Display name for as the department had it configured at run time, - or the raw level when it is no longer configured. Empty when no level was recorded. + or the raw level when it is no longer configured. "-" when no level was recorded. diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 0ea520eef..7f9ed1eb4 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -171,6 +171,10 @@ public void ConfigureServices(IServiceCollection services) // 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(); + // ADP response-boundary net: for a protected department, redacts any value that + // reached the response still carrying an envelope, and logs the surface that missed + // its resolve call. Defence in depth, not a substitute for resolving. + options.Filters.Add(); }).AddNewtonsoftJson(options => { options.SerializerSettings.ContractResolver = new DefaultContractResolver(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 69d291e18..bdbe20c2c 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -2207,6 +2207,11 @@ public async Task CallExport(int callId) var model = new CallExportView(); model.Call = await _callsService.GetCallByIdAsync(callId); model.CallLogs = await _workLogsService.GetCallLogsForCallAsync(callId); + + // calllogs.narrative is cataloged; without this the export renders rgdp ciphertext for a + // protected department. + await _protectedReadService.ResolveCallLogsForReadAsync(DepartmentId, model.CallLogs, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); model.Department = await _departmentsService.GetDepartmentByIdAsync(model.Call.DepartmentId, false); model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(model.Call.DepartmentId, callId)).OrderBy(x => x.UnitId).OrderBy(y => y.Timestamp).ToList(); model.ActionLogs = (await _actionLogsService.GetActionLogsForCallAsync(model.Call.DepartmentId, callId)).OrderBy(x => x.UserId).OrderBy(y => y.Timestamp).ToList(); @@ -2272,6 +2277,8 @@ public async Task CallExportEx(string query) model.DestinationAddress = destinationInfo.Address; model.DestinationTypeName = destinationInfo.TypeName; model.CallLogs = await _workLogsService.GetCallLogsForCallAsync(call.CallId); + await _protectedReadService.ResolveCallLogsForReadAsync(call.DepartmentId, model.CallLogs, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); model.Department = await _departmentsService.GetDepartmentByIdAsync(call.DepartmentId, false); model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(call.DepartmentId, call.CallId)).OrderBy(x => x.UnitId).OrderBy(y => y.Timestamp).ToList(); model.ActionLogs = (await _actionLogsService.GetActionLogsForCallAsync(call.DepartmentId, call.CallId)).OrderBy(x => x.UserId).OrderBy(y => y.Timestamp).ToList(); @@ -2309,6 +2316,8 @@ public async Task CallExportEx(string query) model.DestinationAddress = destinationInfo.Address; model.DestinationTypeName = destinationInfo.TypeName; model.CallLogs = await _workLogsService.GetCallLogsForCallAsync(call.CallId); + await _protectedReadService.ResolveCallLogsForReadAsync(call.DepartmentId, model.CallLogs, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); model.Department = await _departmentsService.GetDepartmentByIdAsync(call.DepartmentId, false); model.UnitStates = (await _unitsService.GetUnitStatesForCallAsync(call.DepartmentId, call.CallId)).OrderBy(x => x.UnitId).OrderBy(y => y.Timestamp).ToList(); model.ActionLogs = (await _actionLogsService.GetActionLogsForCallAsync(call.DepartmentId, call.CallId)).OrderBy(x => x.UserId).OrderBy(y => y.Timestamp).ToList(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index 52d6f9964..70f6f344f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -78,6 +78,10 @@ public class HomeController : SecureBaseController private readonly IEncryptionService _encryptionService; private readonly IExternalIdentityLinkService _externalIdentityLinkService; private readonly IUserSessionService _userSessionService; + private readonly IDepartmentMemberEmergencyContactService _emergencyContactService; + private readonly IProtectedReadService _protectedReadService; + private readonly IDepartmentMemberSensitiveDataService _memberSensitiveDataService; + private readonly IDepartmentDataProtectionService _dataProtectionService; public HomeController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, IUserStateService userStateService, IDepartmentGroupsService departmentGroupsService, Resgrid.Model.Services.IAuthorizationService authorizationService, @@ -89,7 +93,10 @@ public HomeController(IDepartmentsService departmentsService, IUsersService user IStringLocalizer secLocalizer, IGdprDataExportService gdprDataExportService, ISystemAuditsService systemAuditsService, IPhoneNumberProcesserProvider phoneNumberProcesser, ISecurityPinService securityPinService, IEncryptionService encryptionService, - IExternalIdentityLinkService externalIdentityLinkService, IUserSessionService userSessionService) + IExternalIdentityLinkService externalIdentityLinkService, IUserSessionService userSessionService, + IDepartmentMemberEmergencyContactService emergencyContactService, IProtectedReadService protectedReadService, + IDepartmentMemberSensitiveDataService memberSensitiveDataService, + IDepartmentDataProtectionService dataProtectionService) { _departmentsService = departmentsService; _usersService = usersService; @@ -123,6 +130,10 @@ public HomeController(IDepartmentsService departmentsService, IUsersService user _encryptionService = encryptionService; _externalIdentityLinkService = externalIdentityLinkService; _userSessionService = userSessionService; + _emergencyContactService = emergencyContactService; + _protectedReadService = protectedReadService; + _memberSensitiveDataService = memberSensitiveDataService; + _dataProtectionService = dataProtectionService; _localizer = factory.Create("Home.Dashboard", new AssemblyName(typeof(SupportedLocales).GetTypeInfo().Assembly.FullName).Name); } @@ -426,6 +437,7 @@ public async Task EditUserProfile(string userId) model.Email = model.User.Email; model.Profile = await _userProfileService.GetProfileByUserIdAsync(userId, true); + await HydrateMemberIdentificationNumberAsync(model, userId); if (model.Profile == null) model.Profile = new UserProfile(); @@ -443,7 +455,29 @@ public async Task EditUserProfile(string userId) else model.HasCustomIamge = true; - if (model.Profile != null && model.Profile.HomeAddressId.HasValue) + // The department-scoped address is authoritative once the member has one (plan 5.1) — + // an address can differ per department, and only this copy is protected. The legacy + // shared-Addresses link is read only until the contract migration clears it. + var memberAddresses = await _memberSensitiveDataService.GetByDepartmentAndUserAsync(DepartmentId, userId); + if (memberAddresses != null) + await _protectedReadService.ResolveMemberSensitiveDataForReadAsync(DepartmentId, new[] { memberAddresses }, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); + + // The legacy shared-Addresses link is read ONLY while the department is unprotected and + // relocation has not reached this member yet. Once protection is enforced that link is a + // plaintext copy of data this department has already encrypted, and rendering it would + // walk straight around the reveal pipeline. + var legacyAddressFallbackAllowed = !await _dataProtectionService.IsProtectionEnforcedAsync(DepartmentId); + + if (memberAddresses != null && !string.IsNullOrWhiteSpace(memberAddresses.HomeAddress1)) + { + model.PhysicalAddress1 = memberAddresses.HomeAddress1; + model.PhysicalCity = memberAddresses.HomeCity; + model.PhysicalCountry = memberAddresses.HomeCountry; + model.PhysicalPostalCode = memberAddresses.HomePostalCode; + model.PhysicalState = memberAddresses.HomeState; + } + else if (legacyAddressFallbackAllowed && model.Profile != null && model.Profile.HomeAddressId.HasValue) { var homeAddress = await _addressService.GetAddressByIdAsync(model.Profile.HomeAddressId.Value); model.PhysicalAddress1 = homeAddress.Address1; @@ -453,19 +487,39 @@ public async Task EditUserProfile(string userId) model.PhysicalState = homeAddress.State; } - if (model.Profile != null && model.Profile.MailingAddressId.HasValue && model.Profile.HomeAddressId.HasValue && - (model.Profile.MailingAddressId.Value == model.Profile.HomeAddressId.Value)) + if (memberAddresses != null && !string.IsNullOrWhiteSpace(memberAddresses.MailingAddress1)) { - model.MailingAddressSameAsPhysical = true; + model.MailingAddress1 = memberAddresses.MailingAddress1; + model.MailingCity = memberAddresses.MailingCity; + model.MailingCountry = memberAddresses.MailingCountry; + model.MailingPostalCode = memberAddresses.MailingPostalCode; + model.MailingState = memberAddresses.MailingState; + + // The department-scoped copies are independent rows, so "same as physical" is a value + // comparison rather than the legacy shared-address-id check. + model.MailingAddressSameAsPhysical = + string.Equals(memberAddresses.MailingAddress1, memberAddresses.HomeAddress1, StringComparison.OrdinalIgnoreCase) && + string.Equals(memberAddresses.MailingCity ?? string.Empty, memberAddresses.HomeCity ?? string.Empty, StringComparison.OrdinalIgnoreCase) && + string.Equals(memberAddresses.MailingState ?? string.Empty, memberAddresses.HomeState ?? string.Empty, StringComparison.OrdinalIgnoreCase) && + string.Equals(memberAddresses.MailingPostalCode ?? string.Empty, memberAddresses.HomePostalCode ?? string.Empty, StringComparison.OrdinalIgnoreCase) && + string.Equals(memberAddresses.MailingCountry ?? string.Empty, memberAddresses.HomeCountry ?? string.Empty, StringComparison.OrdinalIgnoreCase); } - else if (model.Profile != null && model.Profile.MailingAddressId.HasValue) + else if (legacyAddressFallbackAllowed && model.Profile != null && model.Profile.MailingAddressId.HasValue) { - var mailingAddress = await _addressService.GetAddressByIdAsync(model.Profile.MailingAddressId.Value); - model.MailingAddress1 = mailingAddress.Address1; - model.MailingCity = mailingAddress.City; - model.MailingCountry = mailingAddress.Country; - model.MailingPostalCode = mailingAddress.PostalCode; - model.MailingState = mailingAddress.State; + if (model.Profile.HomeAddressId.HasValue && + model.Profile.MailingAddressId.Value == model.Profile.HomeAddressId.Value) + { + model.MailingAddressSameAsPhysical = true; + } + else + { + var mailingAddress = await _addressService.GetAddressByIdAsync(model.Profile.MailingAddressId.Value); + model.MailingAddress1 = mailingAddress.Address1; + model.MailingCity = mailingAddress.City; + model.MailingCountry = mailingAddress.Country; + model.MailingPostalCode = mailingAddress.PostalCode; + model.MailingState = mailingAddress.State; + } } if (model.Profile != null) @@ -718,9 +772,6 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo if (ModelState.IsValid) { - Address homeAddress = null; - Address mailingAddress = null; - var auditEvent = new AuditEvent(); auditEvent.DepartmentId = DepartmentId; auditEvent.UserId = UserId; @@ -758,7 +809,13 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo savedProfile.HomeNumber = (homeResult != null && homeResult.IsValid && !string.IsNullOrWhiteSpace(homeResult.InternationalNumber)) ? homeResult.InternationalNumber : model.Profile.HomeNumber; - savedProfile.IdentificationNumber = model.Profile.IdentificationNumber; + // The identification number is DEPARTMENT-SCOPED (ADP plan 5.1): a profile row is + // global to the user, so it can neither be encrypted with one department's key nor + // hold the different numbers different departments issue the same person. The + // profile column is left untouched here — it is dropped in the contract migration + // once this is deployed. + await SaveMemberIdentificationNumberAsync(model.UserId, model.Profile.IdentificationNumber, cancellationToken); + await SaveMemberAddressesAsync(model, cancellationToken); savedProfile.TimeZone = model.Profile.TimeZone; savedProfile.Language = model.Profile.Language; @@ -823,48 +880,12 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo await _personnelRolesService.SetRolesForUserAsync(DepartmentId, model.UserId, roles, cancellationToken); } - if (savedProfile.HomeAddressId.HasValue) - homeAddress = await _addressService.GetAddressByIdAsync(savedProfile.HomeAddressId.Value); - - if (savedProfile.MailingAddressId.HasValue) - mailingAddress = await _addressService.GetAddressByIdAsync(savedProfile.MailingAddressId.Value); - - if (!model.MailingAddressSameAsPhysical && homeAddress != null && mailingAddress != null && - (homeAddress.AddressId == mailingAddress.AddressId)) - mailingAddress = new Address(); - - if (!String.IsNullOrWhiteSpace(model.PhysicalAddress1)) - { - if (homeAddress == null) - homeAddress = new Address(); - - homeAddress.Address1 = model.PhysicalAddress1; - homeAddress.City = model.PhysicalCity; - homeAddress.Country = model.PhysicalCountry; - homeAddress.PostalCode = model.PhysicalPostalCode; - homeAddress.State = model.PhysicalState; - - homeAddress = await _addressService.SaveAddressAsync(homeAddress, cancellationToken); - savedProfile.HomeAddressId = homeAddress.AddressId; - - if (model.MailingAddressSameAsPhysical) - savedProfile.MailingAddressId = homeAddress.AddressId; - } - - if (!String.IsNullOrWhiteSpace(model.MailingAddress1) && !model.MailingAddressSameAsPhysical) - { - if (mailingAddress == null) - mailingAddress = new Address(); - - mailingAddress.Address1 = model.MailingAddress1; - mailingAddress.City = model.MailingCity; - mailingAddress.Country = model.MailingCountry; - mailingAddress.PostalCode = model.MailingPostalCode; - mailingAddress.State = model.MailingState; - - mailingAddress = await _addressService.SaveAddressAsync(mailingAddress, cancellationToken); - savedProfile.MailingAddressId = mailingAddress.AddressId; - } + // Addresses are NOT written back to the shared Addresses table or relinked on the + // profile. SaveMemberAddressesAsync above is the only writer now (plan 5.1): the + // department-scoped copy is the one that can be encrypted, and keeping a second + // plaintext copy in sync would recreate exactly the leak this move exists to close. + // The legacy link is left as it stands for members relocation has not reached yet; + // the contract migration clears it. if (model.IsFreePlan) { @@ -1344,6 +1365,244 @@ public async Task GetDataExportStatus() } #endregion GDPR Data Export + + #region Emergency contacts + + /// + /// A member's department-scoped emergency contacts. Authorization reuses + /// CanUserEditProfileAsync — the same rule the rest of this page runs on: a member manages + /// their own, a department admin (or a group admin over that member) manages anyone's. + /// Protected departments resolve through the read pipeline, so values arrive as plaintext + /// with a valid grant and as the REDACTED placeholder without one — never as ciphertext. + /// + [HttpGet] + public async Task GetEmergencyContacts(string userId) + { + if (string.IsNullOrWhiteSpace(userId)) + userId = UserId; + + if (!await _authorizationService.CanUserEditProfileAsync(UserId, DepartmentId, userId)) + return Unauthorized(); + + var contacts = await _emergencyContactService.GetAllForMemberAsync(DepartmentId, userId); + + await _protectedReadService.ResolveMemberEmergencyContactsForReadAsync(DepartmentId, contacts, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); + + return Json(contacts.Select(c => new + { + id = c.DepartmentMemberEmergencyContactId, + name = c.Name, + relationship = c.Relationship, + phoneNumber = c.PhoneNumber, + alternatePhoneNumber = c.AlternatePhoneNumber, + email = c.Email, + notes = c.Notes, + isPrimary = c.IsPrimary, + sortOrder = c.SortOrder + })); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task SaveEmergencyContact([FromForm] EmergencyContactInput input, CancellationToken cancellationToken) + { + if (input == null) + return BadRequest(); + + var targetUserId = string.IsNullOrWhiteSpace(input.UserId) ? UserId : input.UserId; + + if (!await _authorizationService.CanUserEditProfileAsync(UserId, DepartmentId, targetUserId)) + return Unauthorized(); + + if (string.IsNullOrWhiteSpace(input.Name)) + return Json(new { success = false, error = "name_required" }); + + DepartmentMemberEmergencyContact contact; + if (input.Id > 0) + { + // Load through the member-scoped accessor so an id from another member (or another + // department) can never be edited by guessing it. + var existing = await _emergencyContactService.GetAllForMemberAsync(DepartmentId, targetUserId); + contact = existing.FirstOrDefault(x => x.DepartmentMemberEmergencyContactId == input.Id); + + if (contact == null) + return Json(new { success = false, error = "not_found" }); + } + else + { + contact = new DepartmentMemberEmergencyContact + { + DepartmentId = DepartmentId, + UserId = targetUserId, + CreatedByUserId = UserId + }; + } + + // A field the caller never had revealed comes back as the sentinel. Keep whatever is + // stored (already an envelope for a protected department) rather than overwriting a + // third party's next-of-kin details with a placeholder or a blank. + void Apply(string submitted, Action set) + { + if (submitted != ProtectedDataEnvelope.RedactionValue) + set(submitted); + } + + Apply(input.Name, v => contact.Name = v); + Apply(input.Relationship, v => contact.Relationship = v); + Apply(input.PhoneNumber, v => contact.PhoneNumber = v); + Apply(input.AlternatePhoneNumber, v => contact.AlternatePhoneNumber = v); + Apply(input.Email, v => contact.Email = v); + Apply(input.Notes, v => contact.Notes = v); + contact.IsPrimary = input.IsPrimary; + contact.SortOrder = input.SortOrder; + contact.UpdatedByUserId = UserId; + + var saved = await _emergencyContactService.SaveAsync(contact, cancellationToken); + + return Json(new { success = true, id = saved.DepartmentMemberEmergencyContactId }); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task DeleteEmergencyContact([FromForm] int id, [FromForm] string userId, + CancellationToken cancellationToken) + { + var targetUserId = string.IsNullOrWhiteSpace(userId) ? UserId : userId; + + if (!await _authorizationService.CanUserEditProfileAsync(UserId, DepartmentId, targetUserId)) + return Unauthorized(); + + // The service scopes the delete by department AND user, so a stray id cannot reach + // another member's row even past the check above. + var deleted = await _emergencyContactService.DeleteAsync(id, DepartmentId, targetUserId, UserId, cancellationToken); + + return Json(new { success = deleted }); + } + + public class EmergencyContactInput + { + public int Id { get; set; } + public string UserId { get; set; } + public string Name { get; set; } + public string Relationship { get; set; } + public string PhoneNumber { get; set; } + public string AlternatePhoneNumber { get; set; } + public string Email { get; set; } + public string Notes { get; set; } + public bool IsPrimary { get; set; } + public int SortOrder { get; set; } + } + + #endregion Emergency contacts + + + /// + /// Loads the member's department-scoped identification number onto the profile view model. + /// Protected departments resolve it through the read pipeline, so it arrives as plaintext + /// with a valid grant and as the REDACTED placeholder without one — never as ciphertext. + /// + private async Task HydrateMemberIdentificationNumberAsync(EditProfileModel model, string userId) + { + if (model?.Profile == null) + return; + + var sensitive = await _memberSensitiveDataService.GetByDepartmentAndUserAsync(DepartmentId, userId); + if (sensitive == null) + { + model.Profile.IdentificationNumber = null; + return; + } + + await _protectedReadService.ResolveMemberSensitiveDataForReadAsync(DepartmentId, new[] { sensitive }, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); + + model.Profile.IdentificationNumber = sensitive.IdentificationNumber; + } + + /// + /// Persists the member's department-scoped home and mailing addresses (plan 5.1). Values + /// still showing the REDACTED placeholder were never revealed to this user and are skipped + /// rather than written back over the stored address. + /// + private async Task SaveMemberAddressesAsync(EditProfileModel model, CancellationToken cancellationToken) + { + if (model == null) + return; + + var sensitive = await _memberSensitiveDataService.GetByDepartmentAndUserAsync(DepartmentId, model.UserId); + var isNewRow = sensitive == null; + + // The sentinel means "this value was never revealed to me", NOT "clear it". Assigning it + // as null would wipe the member's stored (encrypted) address whenever anyone edited an + // unrelated profile field without a grant. An empty string is different — that is a + // deliberate clear and is honoured. + bool Unchanged(string value) => value == ProtectedDataEnvelope.RedactionValue; + + void Apply(string submitted, Action set) + { + if (!Unchanged(submitted)) + set(submitted); + } + + var home1 = model.PhysicalAddress1; + var mailing1 = model.MailingAddressSameAsPhysical ? model.PhysicalAddress1 : model.MailingAddress1; + + if (isNewRow) + { + // Nothing stored yet, so there is nothing a sentinel could protect; if the form + // carries no address at all there is nothing to create either. + if ((Unchanged(home1) || string.IsNullOrWhiteSpace(home1)) && + (Unchanged(mailing1) || string.IsNullOrWhiteSpace(mailing1))) + return; + + sensitive = new DepartmentMemberSensitiveData { DepartmentId = DepartmentId, UserId = model.UserId }; + } + + Apply(home1, v => sensitive.HomeAddress1 = v); + Apply(model.PhysicalCity, v => sensitive.HomeCity = v); + Apply(model.PhysicalState, v => sensitive.HomeState = v); + Apply(model.PhysicalPostalCode, v => sensitive.HomePostalCode = v); + Apply(model.PhysicalCountry, v => sensitive.HomeCountry = v); + + // "Same as physical" stores a copy rather than a shared reference: these columns are + // encrypted per row, so there is nothing to share and a later edit to one must not + // silently rewrite the other. + Apply(mailing1, v => sensitive.MailingAddress1 = v); + Apply(model.MailingAddressSameAsPhysical ? model.PhysicalCity : model.MailingCity, v => sensitive.MailingCity = v); + Apply(model.MailingAddressSameAsPhysical ? model.PhysicalState : model.MailingState, v => sensitive.MailingState = v); + Apply(model.MailingAddressSameAsPhysical ? model.PhysicalPostalCode : model.MailingPostalCode, v => sensitive.MailingPostalCode = v); + Apply(model.MailingAddressSameAsPhysical ? model.PhysicalCountry : model.MailingCountry, v => sensitive.MailingCountry = v); + + await _memberSensitiveDataService.SaveAsync(sensitive, cancellationToken); + } + + /// + /// Persists the member's department-scoped identification number, creating the row on first + /// use. A value still showing the REDACTED placeholder was never revealed to this user, so it + /// is ignored rather than written back over the stored value. + /// + private async Task SaveMemberIdentificationNumberAsync(string userId, string identificationNumber, + CancellationToken cancellationToken) + { + if (identificationNumber == ProtectedDataEnvelope.RedactionValue) + return; + + var sensitive = await _memberSensitiveDataService.GetByDepartmentAndUserAsync(DepartmentId, userId); + + if (sensitive == null) + { + if (string.IsNullOrWhiteSpace(identificationNumber)) + return; + + sensitive = new DepartmentMemberSensitiveData { DepartmentId = DepartmentId, UserId = userId }; + } + + sensitive.IdentificationNumber = identificationNumber; + + await _memberSensitiveDataService.SaveAsync(sensitive, cancellationToken); + } + } /// Request body for sending a contact verification code. diff --git a/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs b/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs index 91083c8db..c0ef0d616 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/LinksController.cs @@ -223,7 +223,12 @@ public async Task GetActiveCallsList(int linkId) var callJson = new CallListJson(); callJson.CallId = call.CallId; callJson.Number = call.Number; - callJson.Name = call.Name; + // ADP: a linked department is a DIFFERENT tenant — its members can never hold a + // grant for the sharing department, so a protected call name degrades to the + // system-generated number rather than leaking ciphertext across the link. + callJson.Name = ProtectedDataEnvelope.HasEnvelopePrefix(call.Name) + ? Resgrid.Services.ProtectedProjectionService.GenericDispatchText + : call.Name; callJson.State = _callsService.CallStateToString((CallStates) call.State); callJson.StateColor = _callsService.CallStateToColor((CallStates) call.State); callJson.Timestamp = call.LoggedOn.TimeConverterToString(department); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs index 4475a207f..a80dc71d4 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/LogsController.cs @@ -39,11 +39,12 @@ public class LogsController : SecureBaseController private readonly IWorkLogsService _workLogsService; private readonly IEventAggregator _eventAggregator; private readonly IUnitsService _unitsService; + private readonly IProtectedReadService _protectedReadService; public LogsController(IDepartmentsService departmentsService, IUsersService usersService, ICallsService callsService, IDepartmentGroupsService departmentGroupsService, ICommunicationService communicationService, IQueueService queueService, Model.Services.IAuthorizationService authorizationService, IWorkLogsService workLogsService, IEventAggregator eventAggregator, - IUnitsService unitsService) + IUnitsService unitsService, IProtectedReadService protectedReadService) { _departmentsService = departmentsService; _usersService = usersService; @@ -55,6 +56,7 @@ public LogsController(IDepartmentsService departmentsService, IUsersService user _workLogsService = workLogsService; _eventAggregator = eventAggregator; _unitsService = unitsService; + _protectedReadService = protectedReadService; } #endregion Private Members and Constructors @@ -63,6 +65,11 @@ public async Task Index() { LogsIndexView model = new LogsIndexView(); model.CallLogs = await _workLogsService.GetAllCallLogsForUserAsync(UserId); + + // The index lists narratives across every call the member logged; cataloged, so resolved + // rather than rendered as ciphertext. + await _protectedReadService.ResolveCallLogsForReadAsync(DepartmentId, model.CallLogs, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); model.WorkLogs = await _workLogsService.GetAllLogsForUserAsync(UserId); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); @@ -389,7 +396,7 @@ public async Task GetLogsList(string year) logJson.LoggedBy = await UserHelper.GetFullNameForUser(personnelNames, null, log.LoggedByUserId); logJson.LoggedOn = log.LoggedOn.TimeConverterToString(department); - logJson.Narrative = log.Narrative ?? ""; + logJson.Narrative = ProtectedDataEnvelope.SafeDisplay(log.Narrative) ?? ""; // Build search terms from related data var terms = new List(); @@ -426,12 +433,15 @@ public async Task GetLogsList(string year) if (call != null) { - if (!String.IsNullOrWhiteSpace(call.Name)) + // ADP: a protected value must never become a searchable term — an envelope + // here would both leak ciphertext to the client filter and let a caller + // match rows by the "rgdp:" prefix. The system-generated number and id stay. + if (!String.IsNullOrWhiteSpace(call.Name) && !ProtectedDataEnvelope.HasEnvelopePrefix(call.Name)) terms.Add(call.Name); if (!String.IsNullOrWhiteSpace(call.Number)) terms.Add(call.Number); terms.Add(call.CallId.ToString()); - if (!String.IsNullOrWhiteSpace(call.IncidentNumber)) + if (!String.IsNullOrWhiteSpace(call.IncidentNumber) && !ProtectedDataEnvelope.HasEnvelopePrefix(call.IncidentNumber)) terms.Add(call.IncidentNumber); } } @@ -519,6 +529,13 @@ public async Task View(int logId) model.Groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); model.Units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId); + // The log narrative, initial report, cause, contact details and location are cataloged + // (plan 5.2, catalog v3) and this view writes them out with Html.Raw. Without this the + // page would render rgdp ciphertext straight into the document for a protected + // department; resolved here so it is either the real value or the REDACTED placeholder. + await _protectedReadService.ResolveLogsForReadAsync(DepartmentId, new List { model.WorkLog }, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); + if (model.WorkLog.Users != null) { foreach (var logUser in model.WorkLog.Users) @@ -559,6 +576,11 @@ public async Task LogExport(int logId) model.Groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId); model.Units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId); + // Same as the view above: this export renders the cataloged log fields with Html.Raw, so + // it must never receive ciphertext (plan 5.2, catalog v3). + await _protectedReadService.ResolveLogsForReadAsync(DepartmentId, new List { model.WorkLog }, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); + if (model.WorkLog.Users != null) { foreach (var logUser in model.WorkLog.Users) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs b/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs index cccc0141f..ad1914cc6 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/MappingController.cs @@ -42,12 +42,14 @@ public class MappingController : SecureBaseController private readonly IKmlProvider _kmlProvider; private readonly IPermissionsService _permissionsService; private readonly IPersonnelRolesService _personnelRolesService; + private readonly IProtectedReadService _protectedReadService; public MappingController(IDepartmentSettingsService departmentSettingsService, IGeoLocationProvider geoLocationProvider, ICallsService callsService, IDepartmentsService departmentsService, IDepartmentGroupsService departmentGroupsService, IActionLogsService actionLogsService, IUnitsService unitsService, IMappingService mappingService, - IKmlProvider kmlProvider, IPermissionsService permissionsService, IPersonnelRolesService personnelRolesService) + IKmlProvider kmlProvider, IPermissionsService permissionsService, IPersonnelRolesService personnelRolesService, + IProtectedReadService protectedReadService) { _departmentSettingsService = departmentSettingsService; _geoLocationProvider = geoLocationProvider; @@ -60,6 +62,7 @@ public MappingController(IDepartmentSettingsService departmentSettingsService, _kmlProvider = kmlProvider; _permissionsService = permissionsService; _personnelRolesService = personnelRolesService; + _protectedReadService = protectedReadService; } public async Task Index() @@ -648,6 +651,16 @@ await _geoLocationProvider.GetLatLonFromAddress(string.Format("{0} {1} {2} {3}", { foreach (var call in calls) { + // ADP (plan sections 7.1, 7.3): this map payload is built server-side with no + // grant in play, so a protected call contributes NO marker — neither its + // user-authored label nor its exact position may leave here, and ciphertext + // must never reach a map client. + if (ProtectedDataEnvelope.HasEnvelopePrefix(call.Name) || + ProtectedDataEnvelope.HasEnvelopePrefix(call.NatureOfCall) || + ProtectedDataEnvelope.HasEnvelopePrefix(call.GeoLocationData) || + ProtectedDataEnvelope.HasEnvelopePrefix(call.Address)) + continue; + MapMakerInfo info = new MapMakerInfo(); info.ImagePath = "Call"; info.Title = call.Name; @@ -842,19 +855,40 @@ public async Task LiveRouting(int callId) if (call.DepartmentId != DepartmentId) return Unauthorized(); - string endLat = ""; - string endLon = ""; - - var callCocationParts = call.GeoLocationData.Split(char.Parse(",")); - endLat = callCocationParts[0]; - endLon = callCocationParts[1]; + var liveCoordinates = await TryResolveCallCoordinatesAsync(call); + if (!liveCoordinates.Resolved) + return NotFound(); - model.EndLat = endLat; - model.EndLon = endLon; + model.EndLat = liveCoordinates.Latitude; + model.EndLon = liveCoordinates.Longitude; return View(model); } + /// + /// Resolves a call's coordinates for routing. Calls.GeoLocationData is cataloged, so for a + /// protected department it arrives as an rgdp envelope, which contains no comma — the old + /// unguarded Split(",")[1] threw IndexOutOfRange and took the whole page down. A caller + /// without a grant simply cannot route to a concealed location, which is the point, so this + /// reports "no coordinates" rather than failing. + /// + private async Task<(bool Resolved, string Latitude, string Longitude)> TryResolveCallCoordinatesAsync(Call call) + { + await _protectedReadService.ResolveForReadAsync(call.DepartmentId, new List { call }, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); + + var coordinates = call.GeoLocationData; + if (String.IsNullOrWhiteSpace(coordinates) || coordinates == ProtectedDataEnvelope.RedactionValue || + ProtectedDataEnvelope.HasEnvelopePrefix(coordinates)) + return (false, null, null); + + var parts = coordinates.Split(char.Parse(",")); + if (parts.Length < 2) + return (false, null, null); + + return (true, parts[0], parts[1]); + } + [HttpGet] public async Task StationRouting(int stationId, int callId) { @@ -894,17 +928,14 @@ public async Task StationRouting(int stationId, int callId) } } - string endLat = ""; - string endLon = ""; - - var callCocationParts = call.GeoLocationData.Split(char.Parse(",")); - endLat = callCocationParts[0]; - endLon = callCocationParts[1]; + var callCoordinates = await TryResolveCallCoordinatesAsync(call); + if (!callCoordinates.Resolved) + return NotFound(); model.StartLat = startLat; model.StartLon = startLon; - model.EndLat = endLat; - model.EndLon = endLon; + model.EndLat = callCoordinates.Latitude; + model.EndLon = callCoordinates.Longitude; return View(model); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index fd590e490..e74d727d6 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -1373,7 +1373,7 @@ public async Task GetPersonnelStatusDestinationHtmlForDropdown(in sb.Append($""); foreach (var call in activeCalls) { - var callText = HttpUtility.HtmlEncode($"{callPrefix} {call.GetIdentifier()}:{call.Name}"); + var callText = HttpUtility.HtmlEncode($"{callPrefix} {call.GetIdentifier()}:{ProtectedDataEnvelope.SafeDisplay(call.Name)}"); sb.Append($""); } sb.Append(""); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs index 3a9054350..9c37631df 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs @@ -64,6 +64,7 @@ public class ProfileController : SecureBaseController private readonly IDepartmentSettingsService _departmentSettingsService; private readonly IPasswordRecoveryService _passwordRecoveryService; private readonly IEventAggregator _eventAggregator; + private readonly IProtectedReadService _protectedReadService; public ProfileController(IDepartmentsService departmentsService, IUsersService usersService, Model.Services.IAuthorizationService authorizationService, IUserProfileService userProfileService, IScheduledTasksService scheduledTasksService, ICertificationService certificationService, @@ -74,7 +75,7 @@ public ProfileController(IDepartmentsService departmentsService, IUsersService u IExternalIdentityLinkService externalIdentityLinkService, IUserSessionService userSessionService, ISystemAuditsService systemAuditsService, IDepartmentGroupsService departmentGroupsService, IDepartmentSettingsService departmentSettingsService, IPasswordRecoveryService passwordRecoveryService, - IEventAggregator eventAggregator) + IEventAggregator eventAggregator, IProtectedReadService protectedReadService) { _departmentsService = departmentsService; _usersService = usersService; @@ -98,6 +99,7 @@ public ProfileController(IDepartmentsService departmentsService, IUsersService u _departmentSettingsService = departmentSettingsService; _passwordRecoveryService = passwordRecoveryService; _eventAggregator = eventAggregator; + _protectedReadService = protectedReadService; } #endregion Private Members and Constructors @@ -764,11 +766,21 @@ public async Task Certifications(string userId) { string userToGet = !String.IsNullOrWhiteSpace(userId) ? userId : UserId; + if (!await CanReachCertificationsForAsync(userToGet)) + return Unauthorized(); + var model = new CertificationsView(); model.Certifications= await _certificationService.GetCertificationsByUserIdAsync(userToGet); model.Department= await _departmentsService.GetDepartmentByUserIdAsync(userToGet); model.UserId = userToGet; + // Attended protected read (plan 7.1). Metadata only: the document bytes are stripped + // here and re-fetched by the download endpoint, which opts into decryption. Without a + // grant the text renders as the REDACTED placeholder and the page offers a step-up. + var protectedRead = await _protectedReadService.ResolveCertificationsForReadAsync(DepartmentId, + model.Certifications, Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); + model.IsProtected = protectedRead.IsProtected; + var user= _usersService.GetUserById(userToGet); if (userToGet == UserId) model.Self = true; @@ -783,6 +795,10 @@ public async Task Certifications(string userId) public async Task AddCertification(string userId) { string userToGet = !String.IsNullOrWhiteSpace(userId) ? userId : UserId; + + if (!await CanReachCertificationsForAsync(userToGet)) + return Unauthorized(); + var model = new AddCertificationView(); model.UserId = userToGet; var types = await _certificationService.GetAllCertificationTypesByDepartmentAsync(DepartmentId); @@ -796,6 +812,10 @@ public async Task AddCertification(string userId) [Authorize(Policy = ResgridResources.Profile_View)] public async Task AddCertification(AddCertificationView model, IFormFile fileToUpload, CancellationToken cancellationToken) { + // The subject comes from the form, so it is checked before anything is read or written. + if (model == null || !await CanReachCertificationsForAsync(model.UserId)) + return Unauthorized(); + if (fileToUpload != null && fileToUpload.Length > 0) { var extenion = FileHelper.GetFileExtensionWithoutDot(fileToUpload.FileName); @@ -851,9 +871,9 @@ public async Task AddCertification(AddCertificationView model, IF [Authorize(Policy = ResgridResources.Profile_Update)] public async Task DeleteCertification(int certId, CancellationToken cancellationToken) { - var cert= await _certificationService.GetCertificationByIdAsync(certId); + var cert = await GetAuthorizedCertificationAsync(certId); - if (cert.DepartmentId != DepartmentId) + if (cert == null) return Unauthorized(); string userId = cert.UserId; @@ -871,9 +891,9 @@ public async Task DeleteCertification(int certId, CancellationTok [Authorize(Policy = ResgridResources.Profile_Update)] public async Task EditCertification(EditCertificationView model, IFormFile fileToUpload, CancellationToken cancellationToken) { - var cert= await _certificationService.GetCertificationByIdAsync(model.CertificationId); + var cert = await GetAuthorizedCertificationAsync(model?.CertificationId ?? 0); - if (cert.DepartmentId != DepartmentId) + if (cert == null) return Unauthorized(); if (fileToUpload != null && fileToUpload.Length > 0) @@ -928,11 +948,18 @@ public async Task EditCertification(EditCertificationView model, [Authorize(Policy = ResgridResources.Profile_Update)] public async Task EditCertification(int certId) { - var cert= await _certificationService.GetCertificationByIdAsync(certId); + var cert = await GetAuthorizedCertificationAsync(certId); - if (cert.DepartmentId != DepartmentId) + if (cert == null) return Unauthorized(); + // Resolved so the form is populated with real values for a caller holding a grant, and + // with the REDACTED placeholder otherwise — which the save path recognises and restores + // rather than writing over the stored value. + await _protectedReadService.ResolveCertificationsForReadAsync(DepartmentId, + new List { cert }, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); + var model = new EditCertificationView(); model.CertificationId = cert.PersonnelCertificationId; model.Name = cert.Name; @@ -954,17 +981,102 @@ public async Task EditCertification(int certId) public async Task GetCertificationData(int certId) { - var cert= await _certificationService.GetCertificationByIdAsync(certId); + // This endpoint serves the document itself — a scan carrying the member's name, licence + // number and often their signature. It needs the subject check most of all. + var cert = await GetAuthorizedCertificationAsync(certId); - if (cert.DepartmentId != DepartmentId) + if (cert == null) return Unauthorized(); + // The one endpoint that opts into decrypting the document itself. A concealed payload + // comes back null rather than as ciphertext bytes, so a caller without a grant gets a + // refusal instead of a file full of envelope. The page requests this through the reveal + // module, which puts the grant on the request; a plain link cannot carry a header. + await _protectedReadService.ResolveCertificationsForReadAsync(DepartmentId, + new List { cert }, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId, includeData: true); + + if (cert.Data == null || cert.Data.Length == 0) + return NotFound(); + return new FileContentResult(cert.Data, cert.Filetype) { - FileDownloadName = cert.Filename + FileDownloadName = ProtectedDataEnvelope.SafeDisplay(cert.Filename) }; } + /// + /// Certifications carry licence numbers and scanned documents — protected personnel data + /// (plan 5.1, catalog v6). Department scoping alone is not authorization: it only proves the + /// row belongs to this tenant, not that the caller may see THIS member's record. Every + /// certification action resolves the subject and runs the same rule the rest of the + /// department-scoped member data uses — your own record, or an admin over that member. + /// + private async Task CanReachCertificationsForAsync(string subjectUserId) + { + if (string.IsNullOrWhiteSpace(subjectUserId)) + return false; + + return await _authorizationService.CanUserEditProfileAsync(UserId, DepartmentId, subjectUserId); + } + + /// + /// Loads a certification and confirms the caller may reach it. Returns null when the id does + /// not exist, belongs to another department, or belongs to a member this caller has no + /// business reading — all three are indistinguishable to the caller on purpose. + /// + private async Task GetAuthorizedCertificationAsync(int certId) + { + var certification = await _certificationService.GetCertificationByIdAsync(certId); + + if (certification == null || certification.DepartmentId != DepartmentId) + return null; + + return await CanReachCertificationsForAsync(certification.UserId) ? certification : null; + } + + /// + /// Reveals a member's certification values to a caller holding a valid grant (plan 7.2). + /// Keys are catalog field ids suffixed with the row id, because this page shows many rows + /// and a flat field map could not tell them apart. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Profile_View)] + public async Task RevealCertifications([FromForm] string userId) + { + var userToGet = !String.IsNullOrWhiteSpace(userId) ? userId : UserId; + + // The grant proves the CALLER stepped up; it says nothing about whose data they may + // step up to. Without this, any member holding Profile_View could post another + // member's id and decrypt their licence numbers. + if (!await CanReachCertificationsForAsync(userToGet)) + return Unauthorized(); + + var certifications = await _certificationService.GetCertificationsByUserIdAsync(userToGet); + if (certifications == null || !certifications.Any()) + return Json(new { success = true, fields = new Dictionary() }); + + // Rows are keyed by user, so a caller cannot reach another department's certifications + // through this endpoint even if the ids were guessed. + certifications = certifications.Where(c => c.DepartmentId == DepartmentId).ToList(); + + var resolved = await _protectedReadService.ResolveCertificationsForReadAsync(DepartmentId, + certifications, Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); + + if (resolved.IsProtected && resolved.ProtectedReason != null) + return Json(new { success = false, error = resolved.ProtectedReason }); + + var fields = new Dictionary(); + foreach (var certification in certifications) + { + foreach (var accessor in Resgrid.Services.ProtectedReadService.CertificationFieldAccessors) + fields[$"{accessor.Key}:{certification.PersonnelCertificationId}"] = accessor.Value.Get(certification); + } + + return Json(new { success = true, fields }); + } + [Authorize(Policy = ResgridResources.Profile_View)] [HttpGet] public async Task GetDepartmentCertificationTypes() diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs index e213cef2a..cf41185ab 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs @@ -42,6 +42,8 @@ public class ReportsController : SecureBaseController private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IPersonnelRolesService _personnelRolesService; private readonly IUserProfileService _userProfileService; + private readonly IDepartmentMemberSensitiveDataService _memberSensitiveDataService; + private readonly IProtectedReadService _protectedReadService; private readonly IAddressService _addressService; private readonly IUserStateService _userStateService; private readonly IScheduledTasksService _scheduledTasksService; @@ -66,7 +68,8 @@ public ReportsController(IDepartmentsService departmentsService, IUsersService u ICallsService callsService, IWorkLogsService workLogsService, ICustomStateService customStateService, IAuthorizationService authorizationService, IUnitsService unitsService, IUnitStatesService unitStatesService, - ICalendarService calendarService) + ICalendarService calendarService, IDepartmentMemberSensitiveDataService memberSensitiveDataService, + IProtectedReadService protectedReadService) { _departmentsService = departmentsService; _usersService = usersService; @@ -74,6 +77,8 @@ public ReportsController(IDepartmentsService departmentsService, IUsersService u _departmentGroupsService = departmentGroupsService; _personnelRolesService = personnelRolesService; _userProfileService = userProfileService; + _memberSensitiveDataService = memberSensitiveDataService; + _protectedReadService = protectedReadService; _addressService = addressService; _userStateService = userStateService; _scheduledTasksService = scheduledTasksService; @@ -304,6 +309,7 @@ public async Task PersonnelHoursReportParams() profiles.Add(new UserProfile() { UserId = String.Empty, FirstName = "All", LastName = "Users" }); var users = await _userProfileService.GetAllProfilesForDepartmentAsync(DepartmentId); + await ApplyMemberIdentificationNumbersAsync(DepartmentId, users?.Values); foreach (var u in users) { if (!await _authorizationService.CanUserViewPersonViaMatrixAsync(u.Key, UserId, DepartmentId)) @@ -346,6 +352,7 @@ public async Task PersonnelStaffingHistoryReportParams() profiles.Add(new UserProfile() { UserId = String.Empty, FirstName = "All", LastName = "Users" }); var users = await _userProfileService.GetAllProfilesForDepartmentAsync(DepartmentId); + await ApplyMemberIdentificationNumbersAsync(DepartmentId, users?.Values); foreach (var u in users) { if (!await _authorizationService.CanUserViewPersonViaMatrixAsync(u.Key, UserId, DepartmentId)) @@ -445,6 +452,7 @@ public async Task ActionLogsParams() profiles.Add(new UserProfile() { UserId = String.Empty, FirstName = "All", LastName = "Users" }); var users = await _userProfileService.GetAllProfilesForDepartmentAsync(DepartmentId); + await ApplyMemberIdentificationNumbersAsync(DepartmentId, users?.Values); foreach (var u in users) { if (!await _authorizationService.CanUserViewPersonViaMatrixAsync(u.Key, UserId, DepartmentId)) @@ -716,6 +724,12 @@ private async Task CreatePersonnelReportModel(int departmen var users = await _departmentsService.GetAllUsersForDepartmentUnlimitedMinusDisabledAsync(departmentId); model.Department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + // Identification number and mailing address are department-scoped and protected (plan + // 5.1). Reading them from the global profile would show another department's badge number + // and would hand out an address this department has encrypted; resolved here so a + // protected department renders the REDACTED placeholder instead. + var sensitiveByUser = await _memberSensitiveDataService.GetResolvedForDepartmentAsync(departmentId, null, UserId); + model.RunOn = DateTime.UtcNow.TimeConverter(model.Department); foreach (var user in users) @@ -757,24 +771,23 @@ private async Task CreatePersonnelReportModel(int departmen if (savedProfile != null) { + sensitiveByUser.TryGetValue(user.UserId, out var sensitive); + person.Name = savedProfile.FullName.AsFirstNameLastName; - person.ID = savedProfile.IdentificationNumber; + person.ID = sensitive?.IdentificationNumber; person.MobilePhoneNumber = savedProfile.MobileNumber; - if (savedProfile.MailingAddressId.HasValue) + if (sensitive != null && !string.IsNullOrWhiteSpace(sensitive.MailingAddress1)) { - var mailingAddress = - await _addressService.GetAddressByIdAsync(savedProfile.MailingAddressId.Value); - StringBuilder address = new StringBuilder(); address.Append("
"); - address.Append(mailingAddress.Address1); + address.Append(sensitive.MailingAddress1); address.Append(" 
"); - address.Append(mailingAddress.City); - address.Append(mailingAddress.State); - address.Append(mailingAddress.PostalCode); + address.Append(sensitive.MailingCity); + address.Append(sensitive.MailingState); + address.Append(sensitive.MailingPostalCode); address.Append(" 
"); - address.Append(mailingAddress.Country); + address.Append(sensitive.MailingCountry); address.Append(" 
"); address.Append("
"); @@ -802,6 +815,9 @@ private async Task CreateCertificationsReportModel(int var users = await _departmentsService.GetAllUsersForDepartmentUnlimitedMinusDisabledAsync(departmentId); var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + // Department-scoped, protected identification number (plan 5.1) — never the global column. + var sensitiveByUser = await _memberSensitiveDataService.GetResolvedForDepartmentAsync(departmentId, null, UserId); + model.RunOn = DateTime.UtcNow.TimeConverter(department); foreach (var user in users) @@ -821,7 +837,9 @@ private async Task CreateCertificationsReportModel(int if (savedProfile != null) { person.Name = savedProfile.FullName.AsFirstNameLastName; - person.ID = savedProfile.IdentificationNumber; + person.ID = sensitiveByUser.TryGetValue(user.UserId, out var sensitive) + ? sensitive.IdentificationNumber + : null; } else { @@ -829,18 +847,20 @@ private async Task CreateCertificationsReportModel(int person.Name = userProfile.FullName.AsFirstNameLastName; } + // Reports have no reveal step, so a protected department's certification values + // render as the placeholder rather than as ciphertext (plan 5.1, catalog v6). foreach (var certification in certifications) { var subRow = new CertificationsReportSubRow(); - subRow.Name = certification.Name; - subRow.Number = certification.Number; - subRow.Type = certification.Type; - subRow.IssuedBy = certification.IssuedBy; + subRow.Name = ProtectedDataEnvelope.SafeDisplay(certification.Name); + subRow.Number = ProtectedDataEnvelope.SafeDisplay(certification.Number); + subRow.Type = ProtectedDataEnvelope.SafeDisplay(certification.Type); + subRow.IssuedBy = ProtectedDataEnvelope.SafeDisplay(certification.IssuedBy); if (certification.ExpiresOn.HasValue) subRow.ExpiresOn = certification.ExpiresOn.Value.ToShortDateString(); - subRow.Area = certification.Area; + subRow.Area = ProtectedDataEnvelope.SafeDisplay(certification.Area); person.SubRows.Add(subRow); } @@ -858,6 +878,13 @@ private async Task CreateLogReportModel(int logId) var model = new LogReportView(); model.Log = await _logService.GetWorkLogByIdAsync(logId); + // The report template writes the narrative, initial report and cause with Html.Raw. + // Resolved rather than SafeDisplayed field by field: one call covers every cataloged log + // field, including any added by a later catalog version, and a caller holding a grant + // still gets the real report. + await _protectedReadService.ResolveLogsForReadAsync(model.Log.DepartmentId, new List { model.Log }, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); + var department = await _departmentsService.GetDepartmentByIdAsync(model.Log.DepartmentId); model.RunOn = DateTime.UtcNow.TimeConverter(department); @@ -1040,6 +1067,7 @@ await _workLogsService.GetAllLogsByDepartmentDateRangeAsync(DepartmentId, LogTyp model.Responses = new List(); var personnel = await _departmentsService.GetAllUsersForDepartmentAsync(DepartmentId); var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(DepartmentId); + await ApplyMemberIdentificationNumbersAsync(DepartmentId, profiles?.Values); CultureInfo culture = new CultureInfo("en-us"); Calendar calendar = culture.Calendar; @@ -1181,6 +1209,7 @@ await _workLogsService.GetAllLogsByDepartmentDateRangeAsync(DepartmentId, LogTyp var personnel = await _departmentsService.GetAllUsersForDepartmentAsync(DepartmentId); var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(DepartmentId); + await ApplyMemberIdentificationNumbersAsync(DepartmentId, profiles?.Values); foreach (var person in personnel) { @@ -1305,6 +1334,7 @@ await _workLogsService.GetAllLogsByDepartmentDateRangeAsync(DepartmentId, LogTyp model.TrainingDetails = new List(); var profile = await _userProfileService.GetProfileByUserIdAsync(userId); + await ApplyMemberIdentificationNumbersAsync(DepartmentId, new[] { profile }); model.ID = profile.IdentificationNumber; model.Name = profile.FullName.AsFirstNameLastName; @@ -1414,6 +1444,7 @@ private async Task PersonnelStaffingHistoryReportM } var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(departmentId); + await ApplyMemberIdentificationNumbersAsync(departmentId, profiles?.Values); var allStates = await _userStateService.GetAllStatesForDepartmentInDateRangeAsync(departmentId, model.Start, model.End); var groups = await _departmentGroupsService.GetAllDepartmentGroupsForDepartmentAsync(departmentId); @@ -1476,7 +1507,7 @@ await _scheduledTasksService.GetScheduledTasksByUserTypeAsync(group.UserId, { var detail = new PersonnelStaffingDetail(); detail.Timestamp = state.Timestamp.TimeConverterToString(model.Department); - detail.Note = state.Note; + detail.Note = ProtectedDataEnvelope.SafeDisplay(state.Note); var customState = await CustomStatesHelper.GetCustomPersonnelStaffing(departmentId, state); detail.State = customState.ButtonText; @@ -1544,10 +1575,10 @@ private async Task CallSummaryReportModel(int departmentId, Dat { var summary = new CallSummary(); summary.Number = call.Number; - summary.Name = call.Name; + summary.Name = ProtectedDataEnvelope.SafeDisplay(call.Name); summary.LoggedOn = call.LoggedOn; summary.ClosedOn = call.ClosedOn; - summary.Type = call.Type; + summary.Type = ProtectedDataEnvelope.SafeDisplay(call.Type); DateTime? onSceneTime = null; var callLogs = logs.Where(x => x.CallId == call.CallId); @@ -1609,6 +1640,7 @@ private async Task PersonnelStatusHistoryReportModel } var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(departmentId); + await ApplyMemberIdentificationNumbersAsync(departmentId, profiles?.Values); var groups = await _departmentGroupsService.GetAllDepartmentGroupsForDepartmentAsync(departmentId); var statuses = new List(); @@ -1666,7 +1698,7 @@ into g { var detail = new PersonnelStatusDetail(); detail.Timestamp = state.Timestamp.TimeConverterToString(model.Department); - detail.Note = state.Note; + detail.Note = ProtectedDataEnvelope.SafeDisplay(state.Note); var customState = await CustomStatesHelper.GetCustomPersonnelStatus(departmentId, state); @@ -1711,6 +1743,7 @@ private async Task UnitStateHistoryReportModel(int departm } var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(departmentId); + await ApplyMemberIdentificationNumbersAsync(departmentId, profiles?.Values); var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(departmentId); var units = await _unitsService.GetUnitsForDepartmentAsync(departmentId); @@ -1774,7 +1807,7 @@ into g { var detail = new UnitStateDetail(); detail.Timestamp = state.Timestamp.TimeConverterToString(model.Department); - detail.Note = state.Note; + detail.Note = ProtectedDataEnvelope.SafeDisplay(state.Note); var customState = await CustomStatesHelper.GetCustomUnitState(state); @@ -1814,9 +1847,9 @@ private async Task ActiveCallsResourcesReportModel(int dep { var summary = new OpenCallResource(); summary.Number = call.Number; - summary.Name = call.Name; + summary.Name = ProtectedDataEnvelope.SafeDisplay(call.Name); summary.LoggedOn = call.LoggedOn.TimeConverter(model.Department); - summary.Type = call.Type; + summary.Type = ProtectedDataEnvelope.SafeDisplay(call.Type); var callData = await _callsService.PopulateCallData(call, true, false, false, true, true, true, false, false, false); @@ -1938,6 +1971,7 @@ private async Task FlaggedCallNotesReportModel(int d } var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(departmentId); + await ApplyMemberIdentificationNumbersAsync(departmentId, profiles?.Values); foreach (var note in flaggedNotes) { @@ -1946,14 +1980,14 @@ private async Task FlaggedCallNotesReportModel(int d if (calls.TryGetValue(note.CallId, out var call)) { row.CallNumber = call.Number; - row.CallName = call.Name; - row.CallType = string.IsNullOrWhiteSpace(call.Type) ? "None" : call.Type; - row.CallAddress = call.Address; + row.CallName = ProtectedDataEnvelope.SafeDisplay(call.Name); + row.CallType = string.IsNullOrWhiteSpace(call.Type) ? "None" : ProtectedDataEnvelope.SafeDisplay(call.Type); + row.CallAddress = ProtectedDataEnvelope.SafeDisplay(call.Address); row.CallLoggedOn = call.LoggedOn.TimeConverter(model.Department); } row.CallNoteId = note.CallNoteId; - row.NoteText = note.Note; + row.NoteText = ProtectedDataEnvelope.SafeDisplay(note.Note); row.NoteTimestamp = note.Timestamp.TimeConverter(model.Department); if (!string.IsNullOrWhiteSpace(note.UserId) && profiles.TryGetValue(note.UserId, out var authorProfile)) @@ -1964,7 +1998,7 @@ private async Task FlaggedCallNotesReportModel(int d row.FlaggedOn = note.FlaggedOn.HasValue ? note.FlaggedOn.Value.TimeConverter(model.Department) : null; - row.FlaggedReason = note.FlaggedReason; + row.FlaggedReason = ProtectedDataEnvelope.SafeDisplay(note.FlaggedReason); if (!string.IsNullOrWhiteSpace(note.FlaggedByUserId) && profiles.TryGetValue(note.FlaggedByUserId, out var flaggedByProfile)) row.FlaggedByName = flaggedByProfile.FullName.AsFirstNameLastName; @@ -1981,14 +2015,14 @@ private async Task FlaggedCallNotesReportModel(int d if (calls.TryGetValue(image.CallId, out var call)) { row.CallNumber = call.Number; - row.CallName = call.Name; - row.CallType = string.IsNullOrWhiteSpace(call.Type) ? "None" : call.Type; - row.CallAddress = call.Address; + row.CallName = ProtectedDataEnvelope.SafeDisplay(call.Name); + row.CallType = string.IsNullOrWhiteSpace(call.Type) ? "None" : ProtectedDataEnvelope.SafeDisplay(call.Type); + row.CallAddress = ProtectedDataEnvelope.SafeDisplay(call.Address); row.CallLoggedOn = call.LoggedOn.TimeConverter(model.Department); } row.CallAttachmentId = image.CallAttachmentId; - row.FileName = image.FileName; + row.FileName = ProtectedDataEnvelope.SafeDisplay(image.FileName); row.ImageTimestamp = image.Timestamp.HasValue ? image.Timestamp.Value.TimeConverter(model.Department) : null; @@ -2001,7 +2035,7 @@ private async Task FlaggedCallNotesReportModel(int d row.FlaggedOn = image.FlaggedOn.HasValue ? image.FlaggedOn.Value.TimeConverter(model.Department) : null; - row.FlaggedReason = image.FlaggedReason; + row.FlaggedReason = ProtectedDataEnvelope.SafeDisplay(image.FlaggedReason); if (!string.IsNullOrWhiteSpace(image.FlaggedByUserId) && profiles.TryGetValue(image.FlaggedByUserId, out var flaggedByProfile)) row.FlaggedByName = flaggedByProfile.FullName.AsFirstNameLastName; @@ -2018,14 +2052,14 @@ private async Task FlaggedCallNotesReportModel(int d if (calls.TryGetValue(file.CallId, out var call)) { row.CallNumber = call.Number; - row.CallName = call.Name; - row.CallType = string.IsNullOrWhiteSpace(call.Type) ? "None" : call.Type; - row.CallAddress = call.Address; + row.CallName = ProtectedDataEnvelope.SafeDisplay(call.Name); + row.CallType = string.IsNullOrWhiteSpace(call.Type) ? "None" : ProtectedDataEnvelope.SafeDisplay(call.Type); + row.CallAddress = ProtectedDataEnvelope.SafeDisplay(call.Address); row.CallLoggedOn = call.LoggedOn.TimeConverter(model.Department); } row.CallAttachmentId = file.CallAttachmentId; - row.FileName = file.FileName; + row.FileName = ProtectedDataEnvelope.SafeDisplay(file.FileName); row.FileTypeName = file.CallAttachmentType switch { 1 => "Dispatch Audio", @@ -2045,7 +2079,7 @@ private async Task FlaggedCallNotesReportModel(int d row.FlaggedOn = file.FlaggedOn.HasValue ? file.FlaggedOn.Value.TimeConverter(model.Department) : null; - row.FlaggedReason = file.FlaggedReason; + row.FlaggedReason = ProtectedDataEnvelope.SafeDisplay(file.FlaggedReason); if (!string.IsNullOrWhiteSpace(file.FlaggedByUserId) && profiles.TryGetValue(file.FlaggedByUserId, out var flaggedByFileProfile)) row.FlaggedByName = flaggedByFileProfile.FullName.AsFirstNameLastName; @@ -2269,5 +2303,20 @@ private async Task BuildEventAttendanceDetailReportMo } #endregion Event Attendance Report + + /// + /// Stamps the DEPARTMENT-SCOPED identification number onto the profiles a report renders + /// (ADP plan 5.1) — a badge number is department-issued and differs between departments, and + /// the global profile column can no longer answer for any one of them. Reports have no + /// step-up flow, so a protected department's number renders as the REDACTED placeholder. + /// + private async Task ApplyMemberIdentificationNumbersAsync(int departmentId, IEnumerable profiles) + { + if (profiles == null) + return; + + await _memberSensitiveDataService.ApplyIdentificationNumbersAsync(departmentId, profiles, null, UserId); + } + } } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs index b378020bf..2cdccf426 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs @@ -1624,7 +1624,7 @@ private static void AppendDestinationOptions(StringBuilder sb, CustomStateDetail sb.Append(""); foreach (var call in activeCalls) { - sb.Append($""); + sb.Append($""); } sb.Append(""); } @@ -1738,7 +1738,7 @@ private static void AppendDestinationMenuEntries(StringBuilder sb, int? unitId, foreach (var call in activeCalls) { var callHref = BuildUnitStateHref(actionWithDestination, unitId, unitIds, state.CustomStateDetailId, (int)DestinationEntityTypes.Call, call.CallId); - var callText = HttpUtility.HtmlEncode($"{call.GetIdentifier()}:{call.Name}"); + var callText = HttpUtility.HtmlEncode($"{call.GetIdentifier()}:{ProtectedDataEnvelope.SafeDisplay(call.Name)}"); sb.Append($"
  • {callText}
  • "); } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Profile/CertificationsView.cs b/Web/Resgrid.Web/Areas/User/Models/Profile/CertificationsView.cs index ab2629c29..1cd2ca87f 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Profile/CertificationsView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Profile/CertificationsView.cs @@ -11,5 +11,11 @@ public class CertificationsView public bool Self { get; set; } public string Name { get; set; } public string UserId { get; set; } + + /// + /// True when this department's certification values are enveloped, so the page renders the + /// step-up banner and the reveal wiring instead of plain text (ADP plan 5.1/7.2). + /// + public bool IsProtected { get; set; } } } \ No newline at end of file diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml index ed5720353..e6f586705 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml @@ -1,5 +1,6 @@ @model Resgrid.Web.Areas.User.Models.Contacts.ViewContactView @inject IStringLocalizer localizer +@inject IStringLocalizer commonLocalizer @using System.Linq @{ ViewBag.Title = "Resgrid | " + @localizer["ViewContactHeader"]; @@ -10,9 +11,9 @@ {
    - Protected contact — encrypted at rest for this department. Authorized users and approved channels may still disclose it. - - + @commonLocalizer["AdpProtectedContact"] — @commonLocalizer["AdpProtectedBannerDescription"] + +
    }
    @@ -614,19 +615,19 @@
    @@ -645,6 +646,18 @@ verifyUrl: '@Url.Action("VerifyStepUp", "DataProtection", new { area = "User" })', revealUrl: '@Url.Action("RevealContact", "Contacts", new { area = "User" })', revealData: { contactId: '@Model.Contact.ContactId' }, + messages: { + invalid_totp: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorInvalidCode"].Value)), + too_many_attempts: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorTooManyAttempts"].Value)), + mfa_not_enrolled: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorMfaNotEnrolled"].Value)), + grants_not_configured: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorGrantsNotConfigured"].Value)), + step_up_required: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorStepUpRequired"].Value)), + grant_expired: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorGrantExpired"].Value)), + grant_revoked: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorGrantRevoked"].Value)), + protected_access_denied: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorAccessDenied"].Value)), + broker_unavailable: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorBrokerUnavailable"].Value)), + generic: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorGeneric"].Value)) + }, antiForgeryToken: $('#adpAntiForgeryForm input[name="__RequestVerificationToken"]').val() }); }); diff --git a/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml index dc301c4bd..6e1d30192 100644 --- a/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/DataProtection/Index.cshtml @@ -1,24 +1,25 @@ @using Resgrid.Model @using Resgrid.Web.Helpers @model Resgrid.Web.Areas.User.Models.DataProtection.DataProtectionIndexView +@inject IStringLocalizer localizer @{ - ViewBag.Title = "Resgrid | Advanced Data Protection"; + ViewBag.Title = "Resgrid | " + @localizer["PageTitle"]; } @Html.AntiForgeryToken()
    -

    Advanced Data Protection

    +

    @localizer["PageTitle"]

    @@ -29,7 +30,7 @@ @if (Model.IsDepartmentLocked) {
    - Migration window active. @(string.IsNullOrWhiteSpace(Model.LockReason) ? "Data entry is paused until the window closes; viewing is unaffected." : Model.LockReason) + @localizer["MigrationWindowActive"] @(string.IsNullOrWhiteSpace(Model.LockReason) ? @localizer["DataEntryPaused"].Value : Model.LockReason)
    } @@ -38,11 +39,11 @@
    -
    Protection Status
    +
    @localizer["ProtectionStatusHeader"]

    - Current state: + @localizer["CurrentState"] @Model.StateName @@ -54,29 +55,29 @@ 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. + @localizer["EnrollmentUnavailable"]
    } 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. + @localizer["AddonRequiredIntro"]

    @if (Model.IsManagingMember) { - Purchase the ADP addon + @localizer["PurchaseAddon"] } else { -

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

    +

    @localizer["ManagingMemberOnlyPurchase"]

    }
    } else if (!Model.IsManagingMember) {
    - The ADP addon is active. Only your department's managing member can complete enrollment. + @localizer["AddonActiveManagingMemberOnly"]
    } break; @@ -84,13 +85,11 @@ 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. + @localizer["EnrollmentQueuedNotice", Model.MigrationWindowStartLocal, Model.MigrationWindowEndLocal, Model.MigrationWindowTimeZone]

    @if (Model.IsManagingMember) { - + }
    break; @@ -100,19 +99,16 @@ 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. + @localizer["MigrationInProgress", Model.MigrationWindowStartLocal, Model.MigrationWindowEndLocal, Model.MigrationWindowTimeZone]
    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.

    +

    @localizer["ProtectionActiveHeader"] @localizer["ProtectionActiveDetail"]

    - 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. + @localizer["TurnOffInstructions"] + @localizer["SubscriptionPage"]

    break; @@ -120,16 +116,13 @@ 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. + @localizer["OffboardingScheduledHeader"] + @localizer["OffboardingScheduledDetail", string.IsNullOrWhiteSpace(Model.OffboardingEffectiveOn) ? @localizer["EndOfBillingPeriod"].Value : Model.OffboardingEffectiveOn]

    +

    @localizer["TurnOffInstructions"]

    @if (Model.IsManagingMember) { - + }
    break; @@ -137,13 +130,13 @@ case DepartmentDataProtectionState.DisableRequested: case DepartmentDataProtectionState.Decrypting:
    - Offboarding is in progress. Protection remains in effect until your data is fully restored to standard storage. + @localizer["OffboardingInProgress"]
    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. + @localizer["MigrationFailed"]
    break; } @@ -160,115 +153,115 @@
    -
    Enrollment Wizard
    +
    @localizer["WizardHeader"]
    @* 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:

    +

    @localizer["Step1Header"]

    +

    @localizer["Step1Intro"]

      -
    • 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.
    • +
    • @localizer["Step1ProtectedLabel"] @localizer["Step1ProtectedList"]
    • +
    • @localizer["Step1PlaintextLabel"] @localizer["Step1PlaintextList"]
    -

    While protected:

    +

    @localizer["Step1WhileProtected"]

      -
    • 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").
    • +
    • @localizer["Step1LimitSearch"]
    • +
    • @localizer["Step1LimitBigBoard"]
    • +
    • @localizer["Step1LimitWorkflow"]
    • +
    • @localizer["Step1LimitNotifications"]
    - Advanced Data Protection alone is not HIPAA or ePCR compliance. It is one control inside a compliance program your agency still owns. + @localizer["Step1NotCompliance"]
    - +
    @* Step 2: Acknowledgements *@ @* Step 3: Preflight *@ @* Step 4: Sizing scan *@ @* Step 5: Window selection + lock consent *@ @* Step 6: Confirm and queue *@
    @@ -280,5 +273,22 @@ @section Scripts { + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml index 86fc04bca..70a8cc971 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/ViewCall.cshtml @@ -62,9 +62,9 @@
    - Protected call — encrypted at rest for this department. Authorized users and approved channels may still disclose it. - - + @commonLocalizer["AdpProtectedCall"] — @commonLocalizer["AdpProtectedBannerDescription"] + +
    @@ -931,19 +931,19 @@
    @@ -1006,6 +1006,18 @@ verifyUrl: '@Url.Action("VerifyStepUp", "DataProtection", new { area = "User" })', revealUrl: '@Url.Action("RevealCall", "Dispatch", new { area = "User" })', revealData: { callId: @(Model.Call.CallId) }, + messages: { + invalid_totp: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorInvalidCode"].Value)), + too_many_attempts: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorTooManyAttempts"].Value)), + mfa_not_enrolled: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorMfaNotEnrolled"].Value)), + grants_not_configured: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorGrantsNotConfigured"].Value)), + step_up_required: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorStepUpRequired"].Value)), + grant_expired: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorGrantExpired"].Value)), + grant_revoked: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorGrantRevoked"].Value)), + protected_access_denied: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorAccessDenied"].Value)), + broker_unavailable: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorBrokerUnavailable"].Value)), + generic: @Html.Raw(Newtonsoft.Json.JsonConvert.SerializeObject(commonLocalizer["AdpErrorGeneric"].Value)) + }, antiForgeryToken: $('#adpAntiForgeryForm input[name="__RequestVerificationToken"]').val() }); }); diff --git a/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml b/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml index 6ec23f58f..a90026a99 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml @@ -1,4 +1,5 @@ @model Resgrid.Web.Areas.User.Models.EditProfileModel +@inject IStringLocalizer adpLocalizer @inject IStringLocalizer localizer @{ ViewData["Title"] = "Resgrid | Edit User Profile"; @@ -354,6 +355,31 @@
    } +

    @adpLocalizer["EmergencyContactsHeader"]

    +
    +
    +

    + @adpLocalizer["EmergencyContactsIntro"] +

    + + + + + + + + + + + + + +
    @adpLocalizer["ContactNameLabel"]@adpLocalizer["RelationshipLabel"]@adpLocalizer["PhoneNumberLabel"]@adpLocalizer["AlternateColumn"]@adpLocalizer["EmailLabel"]@adpLocalizer["PrimaryColumn"]
    + +
    +
    +
    +

    @localizer["PhysicalAddressHeader"]

    @@ -848,10 +874,80 @@ + + @section Scripts { + + + + + } } diff --git a/Web/Resgrid.Web/Filters/ProtectedDataEgressFilter.cs b/Web/Resgrid.Web/Filters/ProtectedDataEgressFilter.cs new file mode 100644 index 000000000..ac379e884 --- /dev/null +++ b/Web/Resgrid.Web/Filters/ProtectedDataEgressFilter.cs @@ -0,0 +1,137 @@ +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.Config; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Web.Filters +{ + /// + /// Response-boundary net for Advanced Data Protection (plan section 7.5). For a department + /// whose protection is enforced, the outgoing view model is walked and any value still carrying + /// an rgdp/rgdpb envelope is replaced with the REDACTED placeholder before the view renders. + /// + /// Why this exists: the catalog proves a field is protected and the binding-parity test proves + /// a read accessor exists, but nothing proves a controller CALLS the resolve method. Four real + /// leaks were found by hand across these surfaces and every one was invisible to the suite. + /// A per-surface list has to be maintained; envelope detection does not. + /// + /// It redacts AND logs. Redacting alone would quietly paper over the missed resolve call, so + /// every hit is logged with the action and the member path — that log line is the bug report. + /// Runs only when protection is enforced, so an unprotected department pays one cached lookup. + /// + public sealed class ProtectedDataEgressFilter : IAsyncResultFilter + { + public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) + { + if (!DataProtectionConfig.EgressScanEnabled) + { + await next(); + return; + } + + var departmentClaim = context.HttpContext.User?.FindFirst(ClaimTypes.PrimaryGroupSid)?.Value; + if (!int.TryParse(departmentClaim, out var departmentId) || departmentId <= 0) + { + await next(); + return; + } + + bool enforced; + try + { + var protectionService = context.HttpContext.RequestServices.GetService(); + enforced = protectionService != null && await protectionService.IsProtectionEnforcedAsync(departmentId); + } + catch (Exception ex) + { + // Fails OPEN deliberately: this is a safety net behind the real resolve calls, and a + // protection-state fault must not blank every page in the product. + Logging.LogException(ex, $"ProtectedDataEgressFilter (web) could not read protection state for department {departmentId}; skipping the scan"); + await next(); + return; + } + + if (!enforced) + { + await next(); + return; + } + + Scan(context, departmentId); + await next(); + } + + private static void Scan(ResultExecutingContext context, int departmentId) + { + try + { + var action = (context.ActionDescriptor as Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor) + ?.DisplayName ?? context.HttpContext.Request.Path.ToString(); + + // A file endpoint that never resolved its payload would stream ciphertext bytes as + // the user's document. There is nothing to redact in a file — refuse it instead. + if (context.Result is FileContentResult file && + ProtectedEgressScanner.HasBinaryEnvelopePrefix(file.FileContents)) + { + Logging.LogError($"ADP egress net: {action} tried to serve an ENCRYPTED file to department {departmentId}; refused. The endpoint is missing its protected read (includeData)."); + context.Result = new NotFoundResult(); + return; + } + + var model = context.Result switch + { + ViewResult view => view.Model, + PartialViewResult partial => partial.Model, + ObjectResult objectResult => objectResult.Value, + JsonResult json => json.Value, + _ => null + }; + + var result = ProtectedEgressScanner.Sanitize(model, maxNodes: DataProtectionConfig.EgressScanMaxNodes); + + // ViewData carries entities too on several of these pages, so it is scanned as well. + if (context.Result is ViewResult viewResult && viewResult.ViewData != null) + { + foreach (var key in viewResult.ViewData.Keys.ToList()) + { + var entry = ProtectedEgressScanner.Sanitize(viewResult.ViewData[key], + maxNodes: DataProtectionConfig.EgressScanMaxNodes); + + if (ProtectedEgressScanner.IsEnvelopedValue(viewResult.ViewData[key])) + { + viewResult.ViewData[key] = ProtectedDataEnvelope.RedactionValue; + result.Redacted++; + result.Paths.Add($"ViewData[{key}]"); + } + + result.Redacted += entry.Redacted; + result.Unfixable += entry.Unfixable; + result.Paths.AddRange(entry.Paths); + } + } + + if (!result.FoundAnything) + return; + + // This is a bug report, not a routine event: a surface reached the response boundary + // without resolving. Paths are member names, never values. + Logging.LogError($"ADP egress net caught unresolved protected data in {action} for department {departmentId}: " + + $"{result.Redacted} redacted, {result.Unfixable} unfixable{(result.Truncated ? ", scan truncated" : "")}. " + + $"Paths: {string.Join(", ", result.Paths.Take(25))}"); + } + catch (Exception ex) + { + // Never let the net itself break a response. + Logging.LogException(ex, "ProtectedDataEgressFilter (web) failed while scanning the result"); + } + } + } +} diff --git a/Web/Resgrid.Web/Startup.cs b/Web/Resgrid.Web/Startup.cs index ca49aa264..09a63ce24 100644 --- a/Web/Resgrid.Web/Startup.cs +++ b/Web/Resgrid.Web/Startup.cs @@ -439,6 +439,10 @@ public void ConfigureServices(IServiceCollection services) // 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(); + // ADP response-boundary net: for a protected department, redacts any value that + // reached the response still carrying an envelope, and logs the surface that missed + // its resolve call. Defence in depth, not a substitute for resolving. + options.Filters.Add(); }).AddJsonOptions(jsonOptions => { jsonOptions.JsonSerializerOptions.PropertyNamingPolicy = null; diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js b/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js index 7acf43153..3e9f72128 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/dataprotection/resgrid.adp.reveal.js @@ -8,7 +8,7 @@ var REDACTED = 'REDACTED'; - var settings = null; // { verifyUrl, revealUrl, revealData, antiForgeryToken } + var settings = null; // { verifyUrl, revealUrl, revealData, antiForgeryToken, messages } var grantToken = null; var expiryTimer = null; var revealed = false; @@ -99,19 +99,27 @@ $('#adpConcealButton').show(); } + // English fallbacks: the host view supplies localized text through settings.messages, keyed by + // the same value-free reason codes the server returns. A missing key still renders something + // readable rather than an empty alert. + var DEFAULT_MESSAGES = { + invalid_totp: 'The verification code is invalid or has expired.', + too_many_attempts: 'Too many verification attempts. Wait a few minutes and try again.', + mfa_not_enrolled: 'Two-factor authentication is not enrolled for this account. Enroll an authenticator app in account security settings first.', + grants_not_configured: 'Protected data access is not configured on this server.', + step_up_required: 'Verification is required again.', + grant_expired: 'The verification window expired. Verify again.', + grant_revoked: 'Access was revoked by a policy change. Verify again.', + protected_access_denied: 'You are not authorized to view this protected data.', + broker_unavailable: 'The protected data service is unavailable. Try again shortly.', + generic: 'The request failed. Try again.' + }; + function errorText(code) { - switch (code) { - case 'invalid_totp': return 'The verification code is invalid or has expired.'; - case 'too_many_attempts': return 'Too many verification attempts. Wait a few minutes and try again.'; - case 'mfa_not_enrolled': return 'Two-factor authentication is not enrolled for this account. Enroll an authenticator app in account security settings first.'; - case 'grants_not_configured': return 'Protected data access is not configured on this server.'; - case 'step_up_required': return 'Verification is required again.'; - case 'grant_expired': return 'The verification window expired. Verify again.'; - case 'grant_revoked': return 'Access was revoked by a policy change. Verify again.'; - case 'protected_access_denied': return 'You are not authorized to view this protected data.'; - case 'broker_unavailable': return 'The protected data service is unavailable. Try again shortly.'; - default: return 'The request failed. Try again.'; - } + var messages = (settings && settings.messages) || {}; + var key = code && Object.prototype.hasOwnProperty.call(DEFAULT_MESSAGES, code) ? code : 'generic'; + + return messages[key] || DEFAULT_MESSAGES[key]; } function doReveal() { @@ -172,7 +180,49 @@ }); } + // Downloads a protected binary payload (a certification document, an attachment) with the + // grant on the request. A plain cannot carry the grant header, so without this a + // protected file would be unreachable from a server-rendered page — the encryption would have + // made the member's own document permanently undownloadable rather than merely concealed. + // The blob is revoked immediately after the save so decrypted bytes do not linger. + function downloadProtected(url, fileName, onError) { + if (!grantToken) { + if (onError) + onError(errorText('step_up_required')); + return; + } + + var headers = new window.Headers(); + headers.append('X-Resgrid-Protected-Grant', grantToken); + + window.fetch(url, { headers: headers, credentials: 'same-origin' }) + .then(function (response) { + if (!response.ok) + throw new Error(response.status === 403 || response.status === 404 + ? 'protected_access_denied' + : 'generic'); + + return response.blob(); + }) + .then(function (blob) { + var objectUrl = window.URL.createObjectURL(blob); + var link = window.document.createElement('a'); + link.href = objectUrl; + link.download = fileName || 'download'; + window.document.body.appendChild(link); + link.click(); + window.document.body.removeChild(link); + window.URL.revokeObjectURL(objectUrl); + }) + .catch(function (error) { + if (onError) + onError(errorText(error && error.message ? error.message : 'generic')); + }); + } + window.resgridAdpReveal = { + download: downloadProtected, + init: function (options) { settings = options; 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 index 15d37b5e8..d4d6f3ca5 100644 --- 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 @@ -21,19 +21,31 @@ var resgrid; } var errorText = { + // Localized by the host view (adpWizardMessages), keyed by the value-free codes + // the server returns. English fallbacks keep the UI readable if a key is missing. '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.', + '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_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.' }; + // The host view supplies localized text in adpWizardMessages, keyed by the same + // value-free codes the server returns; errorText above is the English fallback. + function localized(code) { + var messages = window.adpWizardMessages || {}; + return messages[code] || null; + } + function showError(container, code) { - $(container).html('
    ' + (errorText[code] || errorText['command_failed']) + '
    '); + var text = localized(code) || errorText[code] + || localized('command_failed') || errorText['command_failed']; + $(container).text(''); + $('
    ').text(text).appendTo($(container)); } $(document).ready(function () { @@ -123,7 +135,7 @@ var resgrid; // ── 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.')) + if (!window.confirm(localized('confirm_cancel_queued') || '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) { @@ -133,7 +145,7 @@ var resgrid; }); $('#btnRevokeOffboarding').click(function () { - if (!window.confirm('Keep Advanced Data Protection active? The scheduled offboarding will be cancelled.')) + if (!window.confirm(localized('confirm_revoke_offboarding') || '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) { diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/profile/resgrid.emergencycontacts.js b/Web/Resgrid.Web/wwwroot/js/app/internal/profile/resgrid.emergencycontacts.js new file mode 100644 index 000000000..112e1a04c --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/profile/resgrid.emergencycontacts.js @@ -0,0 +1,151 @@ +// Department-scoped emergency contacts on the profile page. A member manages their own; a +// department admin (or a group admin over that member) manages anyone's — the server enforces +// that on every call, this only shapes the UI. +// +// For a protected department the server returns the REDACTED placeholder instead of the stored +// values when the caller holds no Protected Data Grant. Values are inserted with text() so a +// decrypted value can never execute as markup. +(function (window, $) { + 'use strict'; + + // English fallbacks; the host view supplies localized text through settings.messages. + var DEFAULTS = { + addTitle: 'Add Emergency Contact', editTitle: 'Edit Emergency Contact', + none: 'No emergency contacts have been added.', yes: 'Yes', edit: 'Edit', remove: 'Delete', + nameRequired: 'A name is required.', saveFailed: 'The contact could not be saved.', + confirmRemove: 'Remove this emergency contact?' + }; + + function text(key) { + var messages = (settings && settings.messages) || {}; + return messages[key] || DEFAULTS[key]; + } + + var settings = null; + var contacts = []; + + function cell(value) { + return $('').text(value || ''); + } + + function render() { + var $body = $('#emergencyContactsBody').empty(); + + if (!contacts.length) { + $body.append($('').append( + $('').addClass('text-muted').text(text('none')))); + return; + } + + $.each(contacts, function (_, contact) { + var $row = $('') + .append(cell(contact.name)) + .append(cell(contact.relationship)) + .append(cell(contact.phoneNumber)) + .append(cell(contact.alternatePhoneNumber)) + .append(cell(contact.email)) + .append(cell(contact.isPrimary ? text('yes') : '')); + + var $actions = $(''); + $('') + .text(text('edit')) + .on('click', function () { openModal(contact); }) + .appendTo($actions); + $('') + .text(text('remove')) + .on('click', function () { remove(contact); }) + .appendTo($actions); + + $body.append($row.append($actions)); + }); + } + + function load() { + $.getJSON(settings.listUrl, { userId: settings.userId }) + .done(function (data) { + contacts = data || []; + render(); + }); + } + + function openModal(contact) { + $('#emergencyContactError').hide().text(''); + $('#ecId').val(contact ? contact.id : 0); + $('#ecName').val(contact ? contact.name : ''); + $('#ecRelationship').val(contact ? contact.relationship : ''); + $('#ecPhone').val(contact ? contact.phoneNumber : ''); + $('#ecAltPhone').val(contact ? contact.alternatePhoneNumber : ''); + $('#ecEmail').val(contact ? contact.email : ''); + $('#ecNotes').val(contact ? contact.notes : ''); + $('#ecIsPrimary').prop('checked', contact ? !!contact.isPrimary : false); + $('#emergencyContactModalTitle').text(contact ? text('editTitle') : text('addTitle')); + $('#emergencyContactModal').modal('show'); + } + + function save() { + var name = ($('#ecName').val() || '').trim(); + if (!name) { + $('#emergencyContactError').text(text('nameRequired')).show(); + return; + } + + // A field still showing the placeholder was never revealed to this user. The sentinel is + // posted back AS IS so the server recognises it and keeps the stored value; blanking it + // here instead would tell the server the member had deliberately cleared their + // next-of-kin details. + function submitted(id) { + return ($(id).val() || '').trim(); + } + + $('#ecSaveBtn').prop('disabled', true); + $.post(settings.saveUrl, { + __RequestVerificationToken: settings.antiForgeryToken, + Id: $('#ecId').val(), + UserId: settings.userId, + Name: submitted('#ecName'), + Relationship: submitted('#ecRelationship'), + PhoneNumber: submitted('#ecPhone'), + AlternatePhoneNumber: submitted('#ecAltPhone'), + Email: submitted('#ecEmail'), + Notes: submitted('#ecNotes'), + IsPrimary: $('#ecIsPrimary').is(':checked'), + SortOrder: 0 + }).done(function (response) { + $('#ecSaveBtn').prop('disabled', false); + if (response && response.success) { + $('#emergencyContactModal').modal('hide'); + load(); + return; + } + + $('#emergencyContactError') + .text(response && response.error === 'name_required' ? text('nameRequired') : text('saveFailed')) + .show(); + }).fail(function () { + $('#ecSaveBtn').prop('disabled', false); + $('#emergencyContactError').text(text('saveFailed')).show(); + }); + } + + function remove(contact) { + if (!window.confirm(text('confirmRemove'))) + return; + + $.post(settings.deleteUrl, { + __RequestVerificationToken: settings.antiForgeryToken, + id: contact.id, + userId: settings.userId + }).done(load); + } + + window.resgridEmergencyContacts = { + init: function (options) { + settings = options; + + $('#addEmergencyContactBtn').on('click', function () { openModal(null); }); + $('#ecSaveBtn').on('click', save); + + load(); + } + }; +})(window, jQuery); diff --git a/Workers/Resgrid.Workers.Console/Commands/MemberProfileRelocationCommand.cs b/Workers/Resgrid.Workers.Console/Commands/MemberProfileRelocationCommand.cs new file mode 100644 index 000000000..e3fbac17a --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Commands/MemberProfileRelocationCommand.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using Quidjibo.Commands; + +namespace Resgrid.Workers.Console.Commands +{ + public sealed class MemberProfileRelocationCommand : IQuidjiboCommand + { + public MemberProfileRelocationCommand(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 832701bc8..e6b21bd61 100644 --- a/Workers/Resgrid.Workers.Console/Program.cs +++ b/Workers/Resgrid.Workers.Console/Program.cs @@ -474,6 +474,14 @@ await Client.ScheduleAsync("ADP Migration", Cron.MinuteIntervals(5), stoppingToken); + // Hourly is plenty: the backlog only grows when someone joins a department carrying + // legacy profile data, and it drains to a single indexed query once the move is done. + _logger.Log(LogLevel.Information, "Scheduling Member Profile Relocation"); + await Client.ScheduleAsync("Member Profile Relocation", + new Commands.MemberProfileRelocationCommand(28), + Cron.MinuteIntervals(60), + stoppingToken); + if (SystemBehaviorConfig.Utf8CleanupEnabled) { var utf8CleanupHour = SystemBehaviorConfig.Utf8CleanupHourUtc >= 0 && SystemBehaviorConfig.Utf8CleanupHourUtc <= 23 diff --git a/Workers/Resgrid.Workers.Console/Tasks/MemberProfileRelocationTask.cs b/Workers/Resgrid.Workers.Console/Tasks/MemberProfileRelocationTask.cs new file mode 100644 index 000000000..286477dba --- /dev/null +++ b/Workers/Resgrid.Workers.Console/Tasks/MemberProfileRelocationTask.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 +{ + /// + /// Drains the legacy member-profile relocation backlog (ADP plan section 5.1) — the identification + /// numbers and addresses that M0134 could not move in SQL because their department is already + /// enrolled in ADP, plus members who joined after that migration ran. A finite, self-terminating + /// job: once the backlog is empty each run is a single indexed query. + /// + public sealed class MemberProfileRelocationTask : IQuidjiboHandler + { + private readonly ILogger _logger; + + public MemberProfileRelocationTask(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string Name => "Member Profile Relocation"; + public int Priority => 1; + + public async Task ProcessAsync(MemberProfileRelocationCommand command, IQuidjiboProgress progress, + CancellationToken cancellationToken) + { + progress?.Report(1, $"Starting the {Name} Task"); + + try + { + var logic = new MemberProfileRelocationLogic(); + var result = await logic.Process(cancellationToken); + + if (!result.Item1) + throw new InvalidOperationException(result.Item2); + + _logger.LogInformation("MemberProfileRelocation::{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 index 87286b45c..de608b781 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/AdpMigrationLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/AdpMigrationLogic.cs @@ -38,6 +38,7 @@ public sealed class AdpMigrationLogic private readonly IProtectedFieldCatalog _catalog; private readonly IDepartmentsService _departmentsService; private readonly IEmailService _emailService; + private readonly IMemberProfileRelocationService _relocationService; public AdpMigrationLogic() : this( @@ -48,7 +49,8 @@ public AdpMigrationLogic() Bootstrapper.GetKernel().Resolve(), Bootstrapper.GetKernel().Resolve(), Bootstrapper.GetKernel().Resolve(), - Bootstrapper.GetKernel().Resolve()) + Bootstrapper.GetKernel().Resolve(), + Bootstrapper.GetKernel().Resolve()) { } @@ -56,7 +58,8 @@ public AdpMigrationLogic(IDepartmentLockService lockService, IDepartmentDataProtectionPolicyRepository policyRepository, IDepartmentDataProtectionService protectionService, IDepartmentKeyService keyService, IDepartmentDataMigrationEngine engine, IProtectedFieldCatalog catalog, - IDepartmentsService departmentsService, IEmailService emailService) + IDepartmentsService departmentsService, IEmailService emailService, + IMemberProfileRelocationService relocationService) { _lockService = lockService ?? throw new ArgumentNullException(nameof(lockService)); _policyRepository = policyRepository ?? throw new ArgumentNullException(nameof(policyRepository)); @@ -66,6 +69,7 @@ public AdpMigrationLogic(IDepartmentLockService lockService, _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); _departmentsService = departmentsService ?? throw new ArgumentNullException(nameof(departmentsService)); _emailService = emailService ?? throw new ArgumentNullException(nameof(emailService)); + _relocationService = relocationService ?? throw new ArgumentNullException(nameof(relocationService)); } public async Task> Process(CancellationToken cancellationToken) @@ -103,6 +107,28 @@ await NotifyAdminsAsync(expiredLock.DepartmentId, } } + // 2b) Catalog upgrades due: the code's catalog has advanced past what this + // department was migrated to, so the newly cataloged fields are still landing in + // plaintext. Sweep only those fields; existing envelopes are untouched (the catalog + // version is not an AAD component). The department's OLD CatalogVersion stays on the + // policy row until the upgrade verifies — that is what makes the run resumable + // across nights, since FromCatalogVersion is read straight off the policy. + foreach (var policy in policies.Where(p => + p.State == (int)DepartmentDataProtectionState.Enabled && + p.CatalogVersion < _catalog.Version)) + { + var rows = await _policyRepository.TryTransitionStateAsync(policy.DepartmentId, + DepartmentDataProtectionState.Enabled, DepartmentDataProtectionState.Encrypting, + (int)DepartmentDataProtectionMigrationKind.CatalogUpgrade, WorkerIdentity, cancellationToken); + if (rows > 0) + { + policy.State = (int)DepartmentDataProtectionState.Encrypting; + policy.ActiveMigrationKind = (int)DepartmentDataProtectionMigrationKind.CatalogUpgrade; + await _protectionService.InvalidateProtectionCacheAsync(policy.DepartmentId); + summary.Add($"catalog upgrade v{policy.CatalogVersion}->v{_catalog.Version} queued for department {policy.DepartmentId}"); + } + } + // 3) Pick work, unless the operator has paused the queue. if (DataProtectionConfig.MigrationQueuePaused) { @@ -276,6 +302,10 @@ await NotifyAdminsAsync(departmentId, DepartmentId = departmentId, Kind = kind, CatalogVersion = _catalog.Version, + + // Still the department's OLD version until the run verifies, so a resumed + // upgrade keeps sweeping the same field range. + FromCatalogVersion = policy.CatalogVersion, WindowEndUtc = windowEndUtc, DepartmentOperationLockId = departmentLock.DepartmentOperationLockId, CorrelationId = correlationId, @@ -317,6 +347,16 @@ await NotifyAdminsAsync(departmentId, if (state == DepartmentDataProtectionState.Encrypting) { + // Before the sweep: move any member data still sitting in the legacy global + // location into this department's own rows (plan 5.1), so the night encrypts a + // complete corpus instead of leaving identification numbers and addresses behind + // in plaintext. Safe at any point in a resumed run — relocated values go through + // the normal write path, which envelopes them because the department is already + // encrypting new writes, so it does not matter where the sweep cursor sits. + var relocation = await _relocationService.RelocateDepartmentAsync(departmentId, cancellationToken); + if (relocation.Failures > 0) + Logging.LogError($"ADP migration: {relocation.Failures} member profile relocation(s) failed for department {departmentId}; they retry on the next pass."); + var night = await _engine.RunEncryptionNightAsync(context, cancellationToken); if (night.Outcome == AdpMigrationNightOutcome.WindowClosed) { @@ -334,7 +374,7 @@ await NotifyAdminsAsync(departmentId, } if (await _policyRepository.TryTransitionStateAsync(departmentId, DepartmentDataProtectionState.Encrypting, - DepartmentDataProtectionState.Verifying, (int)DepartmentDataProtectionMigrationKind.Enrollment, + DepartmentDataProtectionState.Verifying, (int)kind, WorkerIdentity, cancellationToken) == 0) return $"department {departmentId}: verify transition race"; @@ -419,7 +459,18 @@ await NotifyAdminsAsync(departmentId, await _policyRepository.SaveOrUpdateAsync(enabledPolicy, cancellationToken); } + // The epoch bump invalidates outstanding grants: after an upgrade a client's cached + // view of which fields are protected is stale, so everyone re-steps-up. await _protectionService.IncrementPolicyEpochAsync(departmentId, WorkerIdentity, cancellationToken); + + if (kind == DepartmentDataProtectionMigrationKind.CatalogUpgrade) + { + await NotifyAdminsAsync(departmentId, + "Advanced Data Protection: additional fields are now protected for your department and verification passed. No action is needed."); + releaseKind = DepartmentOperationLockReleaseKind.Completed; + return $"department {departmentId}: catalog upgrade complete at v{context.CatalogVersion}"; + } + await NotifyAdminsAsync(departmentId, "Advanced Data Protection: verification passed and protection is now ACTIVE for your department."); releaseKind = DepartmentOperationLockReleaseKind.Completed; diff --git a/Workers/Resgrid.Workers.Framework/Logic/MemberProfileRelocationLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/MemberProfileRelocationLogic.cs new file mode 100644 index 000000000..e49299e22 --- /dev/null +++ b/Workers/Resgrid.Workers.Framework/Logic/MemberProfileRelocationLogic.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Autofac; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Workers.Framework.Logic +{ + /// + /// Drains the legacy member-profile relocation backlog (ADP plan section 5.1): identification + /// numbers and addresses that still live on the global UserProfiles row and have to move onto the + /// department-scoped DepartmentMemberSensitiveData row. + /// + /// M0134 moves everything it safely can in SQL at deploy time, but it cannot touch a department + /// already enrolled in ADP — plaintext written into an enrolled row would poison it. This sweep + /// covers exactly the remainder: enrolled departments (the move goes through the ADP write path + /// and is enveloped as it lands) and members who joined after the migration ran. Once the backlog + /// reads zero the sweep costs one indexed query, and zero is the precondition for the contract + /// migration that drops the legacy columns. + /// + /// Departments mid-migration are deliberately skipped: their relocation runs as the first step of + /// the encryption night instead, inside the department's own window and lock. + /// + public sealed class MemberProfileRelocationLogic + { + /// + /// Bound on one pass so a large backlog is drained over several sweeps rather than in one + /// long-running job. Whatever is deferred is named in the summary — a silent cap would read + /// as "the backlog is empty" when it is not. + /// + private const int MaxDepartmentsPerPass = 25; + + private readonly IMemberProfileRelocationService _relocationService; + private readonly IDepartmentDataProtectionService _protectionService; + + public MemberProfileRelocationLogic() + : this( + Bootstrapper.GetKernel().Resolve(), + Bootstrapper.GetKernel().Resolve()) + { + } + + public MemberProfileRelocationLogic(IMemberProfileRelocationService relocationService, + IDepartmentDataProtectionService protectionService) + { + _relocationService = relocationService ?? throw new ArgumentNullException(nameof(relocationService)); + _protectionService = protectionService ?? throw new ArgumentNullException(nameof(protectionService)); + } + + public async Task> Process(CancellationToken cancellationToken) + { + try + { + var outstanding = await _relocationService.GetDepartmentIdsWithOutstandingDataAsync(); + if (outstanding == null || outstanding.Count == 0) + return new Tuple(true, "no outstanding member profile relocations"); + + var summary = new List(); + var deferred = 0; + var processed = 0; + + foreach (var departmentId in outstanding) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (processed >= MaxDepartmentsPerPass) + { + deferred++; + continue; + } + + // Only steady states. A department mid-enrollment, mid-rotation or mid-offboarding + // is moving its whole corpus already; relocating it from here would race that run, + // so the encryption night does it as its own first step instead. + var state = await _protectionService.GetStateAsync(departmentId); + if (state != DepartmentDataProtectionState.Disabled && state != DepartmentDataProtectionState.Enabled) + { + deferred++; + continue; + } + + processed++; + var result = await _relocationService.RelocateDepartmentAsync(departmentId, cancellationToken); + if (result.DidWork) + summary.Add(result.ToString()); + } + + if (deferred > 0) + summary.Add($"{deferred} department(s) deferred to a later pass"); + + return new Tuple(true, summary.Count > 0 + ? string.Join("; ", summary) + : "no member profile relocations were eligible this pass"); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Logging.LogException(ex); + return new Tuple(false, ex.ToString()); + } + } + } +} From f41c6f90ff112424e8540abe5fdf911a69209a4b Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 29 Aug 2026 12:46:10 -0700 Subject: [PATCH 2/4] RG-T89 ADP: PR #489 review fixes --- Core/Resgrid.Services/DeleteService.cs | 8 ++++ .../DepartmentDataProtectionService.cs | 13 ++++++ .../IncidentCommandService.cs | 4 +- Core/Resgrid.Services/ProtectedReadService.cs | 17 ++++++- .../UserDefinedFieldsService.cs | 26 +++++++++++ ...31_AddDepartmentMemberEmergencyContacts.cs | 17 ++++++- .../M0133_AddMemberDepartmentAddresses.cs | 15 ++++++- .../M0134_CompleteMemberProfileRelocation.cs | 44 ++++++++++++++----- ...M0134_CompleteMemberProfileRelocationPg.cs | 34 +++++++++++--- ...DepartmentMemberSensitiveDataRepository.cs | 14 ++++-- .../DepartmentDataProtectionServiceTests.cs | 31 +++++++++++++ .../Services/ProtectedReadServiceTests.cs | 36 +++++++++++++++ .../v4/UserDefinedFieldsController.cs | 6 +++ .../Areas/User/Controllers/HomeController.cs | 22 ++++++++-- .../resgrid.dataprotection.wizard.js | 4 +- 15 files changed, 258 insertions(+), 33 deletions(-) diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs index b404b5c95..22591dc4b 100644 --- a/Core/Resgrid.Services/DeleteService.cs +++ b/Core/Resgrid.Services/DeleteService.cs @@ -122,6 +122,14 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer await _distributionListsService.RemoveUserFromAllListsInDepartmentAsync(userId, departmentId, cancellationToken); await _scheduledTasksService.DeleteAllTasksForUserInDepartmentAsync(userId, departmentId, cancellationToken); + // Department-scoped personal data goes with the membership (ADP plan 5.1). Before the + // relocation these values lived on the global profile and a revoked member simply stopped + // being reachable; now the department holds the only copy of their identification number, + // address and next-of-kin details, so leaving the rows behind would retain a former + // member's personal data under a department that no longer has any relationship with them. + await _memberSensitiveDataService.DeleteForMemberAsync(departmentId, userId, cancellationToken); + await _emergencyContactService.DeleteAllForMemberAsync(departmentId, userId, cancellationToken); + // Soft-delete the membership last (this also writes the audit event and clears caches). var member = await _departmentsService.DeleteUserAsync(departmentId, userId, revokingUserId, cancellationToken); if (member != null && member.IsDeleted) diff --git a/Core/Resgrid.Services/DepartmentDataProtectionService.cs b/Core/Resgrid.Services/DepartmentDataProtectionService.cs index 6afde7fcc..be7c18232 100644 --- a/Core/Resgrid.Services/DepartmentDataProtectionService.cs +++ b/Core/Resgrid.Services/DepartmentDataProtectionService.cs @@ -141,6 +141,19 @@ public async Task IsProtectionEnforcedAsync(int departmentId) return policy?.ActiveMigrationKind == (int)DepartmentDataProtectionMigrationKind.CatalogUpgrade; } + // A FAILED run always enforces, whatever it was doing when it stopped. Every failure + // leaves envelopes at rest: enrollment and catalog upgrade fail part-way through a + // sweep, rotation fails over an already-enveloped corpus, and offboarding fails with + // the decrypt pass incomplete. ShouldEncryptNewWritesAsync already keeps encrypting in + // this state for everything but offboarding, so the two would otherwise disagree — + // writes producing envelopes that reads hand straight to clients. + // + // The cost of being wrong in the other direction is a department seeing REDACTED for + // values that happen to still be plaintext, which is recoverable; serving ciphertext, + // or worse the plaintext behind it, is not. + if (state == DepartmentDataProtectionState.Failed) + return true; + return false; } diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index 0d34938c7..5af62a087 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -857,7 +857,9 @@ static bool IsUnitKind(int kind) => kind == (int)ResourceAssignmentKind.RealUnit Name = command.Name, CallName = ProtectedDataEnvelope.SafeDisplay(call?.Name), CallNumber = call?.Number, - CallAddress = call?.Address, + // calls.address is cataloged PII like calls.name — the number is the one field + // deliberately left in the clear (plan 5.1). + CallAddress = ProtectedDataEnvelope.SafeDisplay(call?.Address), Status = command.Status, EstablishedOn = command.EstablishedOn, ClosedOn = command.ClosedOn, diff --git a/Core/Resgrid.Services/ProtectedReadService.cs b/Core/Resgrid.Services/ProtectedReadService.cs index ff4dc9cfc..69ea5a6fc 100644 --- a/Core/Resgrid.Services/ProtectedReadService.cs +++ b/Core/Resgrid.Services/ProtectedReadService.cs @@ -834,6 +834,7 @@ public async Task PrepareCertificationWriteAsync(int depar return ProtectedWriteResult.Allowed(); var slots = new List(); + var restored = false; var rowKey = certification.PersonnelCertificationId.ToString(CultureInfo.InvariantCulture); foreach (var accessor in CertificationFieldAccessors) { @@ -844,7 +845,11 @@ public async Task PrepareCertificationWriteAsync(int depar if (value == ProtectedDataEnvelope.RedactionValue) { if (existingCertification != null) + { accessor.Value.Set(certification, accessor.Value.Get(existingCertification)); + restored = true; + } + continue; } @@ -870,8 +875,18 @@ public async Task PrepareCertificationWriteAsync(int depar Apply = envelopeBase64 => certification.Data = Convert.FromBase64String(envelopeBase64) }); - return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, () => certification.IsProtected = true, cancellationToken); + + // A restore mutates the entity without producing a broker slot, so EncryptSlotsAsync has + // nothing to report and Changed stays false. The caller re-persists only on Changed, so + // without this the transiently-saved row keeps the literal REDACTED placeholder and the + // member's certification number and file name are gone. Reported through the result + // rather than left to each caller to special-case, the way calls and contacts do. + if (restored && result.Success && !result.Changed) + result.Changed = true; + + return result; } public async Task PrepareContactWriteAsync(int departmentId, Contact contact, Contact existingContact, diff --git a/Core/Resgrid.Services/UserDefinedFieldsService.cs b/Core/Resgrid.Services/UserDefinedFieldsService.cs index a0cb40c6b..01b93722f 100644 --- a/Core/Resgrid.Services/UserDefinedFieldsService.cs +++ b/Core/Resgrid.Services/UserDefinedFieldsService.cs @@ -205,6 +205,32 @@ public async Task>> SaveFieldValuesForEntityAsyn .Select(g => g.Last()) .ToList(); + // REDACTED-sentinel restoration (ADP plan 5.2). udffieldvalues.value is cataloged, and + // the edit surfaces render it through SafeDisplay — so an editor without a grant sees + // the placeholder and posts it straight back. This save is delete-then-reinsert, so + // without the swap below the real value is deleted and the literal placeholder is + // inserted in its place: the member's protected UDF value is gone, and no reveal can + // bring it back. Done before the delete, while the stored values still exist. + var submittedSentinels = normalizedValues + .Where(v => v.Value == ProtectedDataEnvelope.RedactionValue) + .ToList(); + + if (submittedSentinels.Count > 0) + { + var stored = (await GetFieldValuesForEntityAsync(departmentId, entityType, entityId)) + ?.Where(v => !string.IsNullOrEmpty(v.UdfFieldId)) + .GroupBy(v => v.UdfFieldId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Last().Value, StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + + foreach (var value in submittedSentinels) + { + // No stored value means there is nothing behind the placeholder to keep, so the + // field is cleared rather than having the literal word written into it. + value.Value = stored.TryGetValue(value.UdfFieldId, out var existing) ? existing : null; + } + } + // Delete existing values for this entity + definition version, then re-insert. // Wrap both operations in a single transaction so a failed insert cannot leave // the entity with no field values (partial data loss). diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0131_AddDepartmentMemberEmergencyContacts.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0131_AddDepartmentMemberEmergencyContacts.cs index fc3dd93de..cbf1d6672 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0131_AddDepartmentMemberEmergencyContacts.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0131_AddDepartmentMemberEmergencyContacts.cs @@ -53,10 +53,23 @@ public override void Up() Delete.Column("EmergencyContactPhone").FromTable("DepartmentMemberSensitiveData"); } + /// + /// Refuses the rollback when any row is already protected. These columns hold the ONLY copy + /// of an enrolled department's emergency contacts — the plaintext was never kept elsewhere — + /// so dropping the table would destroy them with no way back. A comment saying "only safe + /// while every department is Disabled" is not a control; this is. + /// + private const string RefuseIfProtected = @" +IF EXISTS (SELECT 1 FROM [DepartmentMemberEmergencyContacts] WHERE [IsProtected] = 1) + THROW 51000, 'M0131 rollback refused: protected emergency-contact data exists and this table holds the only copy. Offboard the affected departments first.', 1;"; + public override void Down() { - // Only safe while every department is Disabled: dropping this table on a protected - // department destroys emergency-contact ciphertext that cannot be recovered. + // Refuses rather than relying on the operator having read the comment: dropping this + // table on a protected department destroys emergency-contact ciphertext for good. + if (Schema.Table("DepartmentMemberEmergencyContacts").Exists()) + Execute.Sql(RefuseIfProtected); + if (Schema.Table("DepartmentMemberEmergencyContacts").Exists()) Delete.Table("DepartmentMemberEmergencyContacts"); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0133_AddMemberDepartmentAddresses.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0133_AddMemberDepartmentAddresses.cs index 37439c51c..e5b6a0553 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0133_AddMemberDepartmentAddresses.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0133_AddMemberDepartmentAddresses.cs @@ -54,10 +54,21 @@ FROM [DepartmentMemberSensitiveData] s WHERE s.[MailingAddress1] IS NULL AND s.[IsProtected] = 0;"); } + /// + /// Refuses the rollback when any row is already protected — these columns hold the only copy + /// of an enrolled department's addresses. + /// + private const string RefuseIfProtected = @" +IF EXISTS (SELECT 1 FROM [DepartmentMemberSensitiveData] WHERE [IsProtected] = 1) + THROW 51000, 'M0133 rollback refused: protected member address data exists and these columns hold the only copy. Offboard the affected departments first.', 1;"; + public override void Down() { - // Only safe while every department is Disabled: for a protected department these columns - // hold rgdp ciphertext that exists nowhere else. + // Refuses rather than relying on the operator having read the comment: for a protected + // department these columns hold rgdp ciphertext that exists nowhere else. + if (Schema.Table("DepartmentMemberSensitiveData").Column("HomeAddress1").Exists()) + Execute.Sql(RefuseIfProtected); + if (Schema.Table("DepartmentMemberSensitiveData").Column("HomeAddress1").Exists()) Delete.Column("HomeAddress1").Column("HomeCity").Column("HomeState") .Column("HomePostalCode").Column("HomeCountry") diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0134_CompleteMemberProfileRelocation.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0134_CompleteMemberProfileRelocation.cs index 62c56b7a8..0f1c74971 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0134_CompleteMemberProfileRelocation.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0134_CompleteMemberProfileRelocation.cs @@ -32,6 +32,13 @@ public override void Up() Alter.Table("DepartmentMemberSensitiveData") .AddColumn("LegacyProfileRelocatedOn").AsDateTime2().Nullable(); + // The gate is the DEPARTMENT's protection state, not the row's IsProtected flag. Step 1 + // creates rows with that flag clear, so gating on it would let an enrolled department's + // members be filled with cleartext and then stamped as relocated — leaving plaintext PII + // at rest under a department that is supposed to be encrypted, and marking it done so + // MemberProfileRelocationService skips those members permanently. Enrolled departments + // are left entirely to that service, which moves the values through the ADP write path. + // // 1) Rows for members whose legacy profile holds an address but no identification number // (M0132 skipped them) or who joined a department after M0132 ran. Execute.Sql(@" @@ -44,7 +51,10 @@ FROM [DepartmentMembers] dm OR (up.[IdentificationNumber] IS NOT NULL AND LTRIM(RTRIM(up.[IdentificationNumber])) <> '')) AND NOT EXISTS ( SELECT 1 FROM [DepartmentMemberSensitiveData] s - WHERE s.[DepartmentId] = dm.[DepartmentId] AND s.[UserId] = dm.[UserId]);"); + WHERE s.[DepartmentId] = dm.[DepartmentId] AND s.[UserId] = dm.[UserId]) + AND NOT EXISTS ( + SELECT 1 FROM [DepartmentDataProtectionPolicies] p + WHERE p.[DepartmentId] = dm.[DepartmentId] AND p.[State] <> 0);"); // 2) Fill the three families into any still-empty, still-plaintext target. Re-runnable: // a department-specific value someone has already entered is never overwritten. @@ -54,7 +64,10 @@ UPDATE s FROM [DepartmentMemberSensitiveData] s INNER JOIN [UserProfiles] up ON up.[UserId] = s.[UserId] WHERE s.[IdentificationNumber] IS NULL AND s.[IsProtected] = 0 AND s.[LegacyProfileRelocatedOn] IS NULL - AND up.[IdentificationNumber] IS NOT NULL AND LTRIM(RTRIM(up.[IdentificationNumber])) <> '';"); + AND up.[IdentificationNumber] IS NOT NULL AND LTRIM(RTRIM(up.[IdentificationNumber])) <> '' + AND NOT EXISTS ( + SELECT 1 FROM [DepartmentDataProtectionPolicies] p + WHERE p.[DepartmentId] = s.[DepartmentId] AND p.[State] <> 0);"); Execute.Sql(@" UPDATE s @@ -63,7 +76,10 @@ UPDATE s FROM [DepartmentMemberSensitiveData] s INNER JOIN [UserProfiles] up ON up.[UserId] = s.[UserId] INNER JOIN [Addresses] ha ON ha.[AddressId] = up.[HomeAddressId] -WHERE s.[HomeAddress1] IS NULL AND s.[IsProtected] = 0 AND s.[LegacyProfileRelocatedOn] IS NULL;"); +WHERE s.[HomeAddress1] IS NULL AND s.[IsProtected] = 0 AND s.[LegacyProfileRelocatedOn] IS NULL + AND NOT EXISTS ( + SELECT 1 FROM [DepartmentDataProtectionPolicies] p + WHERE p.[DepartmentId] = s.[DepartmentId] AND p.[State] <> 0);"); Execute.Sql(@" UPDATE s @@ -72,15 +88,23 @@ UPDATE s FROM [DepartmentMemberSensitiveData] s INNER JOIN [UserProfiles] up ON up.[UserId] = s.[UserId] INNER JOIN [Addresses] ma ON ma.[AddressId] = up.[MailingAddressId] -WHERE s.[MailingAddress1] IS NULL AND s.[IsProtected] = 0 AND s.[LegacyProfileRelocatedOn] IS NULL;"); +WHERE s.[MailingAddress1] IS NULL AND s.[IsProtected] = 0 AND s.[LegacyProfileRelocatedOn] IS NULL + AND NOT EXISTS ( + SELECT 1 FROM [DepartmentDataProtectionPolicies] p + WHERE p.[DepartmentId] = s.[DepartmentId] AND p.[State] <> 0);"); - // 3) Stamp what moved. Every plaintext row is now relocated — including rows whose member - // simply had nothing to move, which are relocated by definition and must not be - // revisited by the worker on every pass. + // 3) Stamp what moved, in unprotected departments only. Every plaintext row there is now + // relocated — including rows whose member simply had nothing to move, which are + // relocated by definition and must not be revisited by the worker on every pass. An + // enrolled department's rows stay unstamped so the relocation service still owns them. Execute.Sql(@" -UPDATE [DepartmentMemberSensitiveData] -SET [LegacyProfileRelocatedOn] = GETUTCDATE() -WHERE [LegacyProfileRelocatedOn] IS NULL AND [IsProtected] = 0;"); +UPDATE s +SET s.[LegacyProfileRelocatedOn] = GETUTCDATE() +FROM [DepartmentMemberSensitiveData] s +WHERE s.[LegacyProfileRelocatedOn] IS NULL AND s.[IsProtected] = 0 + AND NOT EXISTS ( + SELECT 1 FROM [DepartmentDataProtectionPolicies] p + WHERE p.[DepartmentId] = s.[DepartmentId] AND p.[State] <> 0);"); } public override void Down() diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0134_CompleteMemberProfileRelocationPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0134_CompleteMemberProfileRelocationPg.cs index b990fe416..f1992e9ef 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0134_CompleteMemberProfileRelocationPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0134_CompleteMemberProfileRelocationPg.cs @@ -19,6 +19,9 @@ public override void Up() // 1) Rows for members whose legacy profile holds an address but no identification number // (M0132 skipped them) or who joined a department after M0132 ran. md5(random()) keeps // ProtectionId's 32-hex shape without depending on the pgcrypto extension. + // The gate is the DEPARTMENT's protection state, not the row's isprotected flag — see the + // SQL Server twin for why. Enrolled departments are left to MemberProfileRelocationService, + // which moves their values through the ADP write path instead of in cleartext. Execute.Sql(@" INSERT INTO departmentmembersensitivedata (departmentid, userid, protectionid, isprotected, createdon) SELECT dm.departmentid, dm.userid, md5(random()::text || clock_timestamp()::text), false, (now() at time zone 'utc') @@ -29,7 +32,10 @@ FROM departmentmembers dm OR (up.identificationnumber IS NOT NULL AND btrim(up.identificationnumber) <> '')) AND NOT EXISTS ( SELECT 1 FROM departmentmembersensitivedata s - WHERE s.departmentid = dm.departmentid AND s.userid = dm.userid);"); + WHERE s.departmentid = dm.departmentid AND s.userid = dm.userid) + AND NOT EXISTS ( + SELECT 1 FROM departmentdataprotectionpolicies p + WHERE p.departmentid = dm.departmentid AND p.state <> 0);"); // 2) Fill the three families into any still-empty, still-plaintext target. Execute.Sql(@" @@ -38,7 +44,10 @@ UPDATE departmentmembersensitivedata s FROM userprofiles up WHERE up.userid = s.userid AND s.identificationnumber IS NULL AND s.isprotected = false AND s.legacyprofilerelocatedon IS NULL - AND up.identificationnumber IS NOT NULL AND btrim(up.identificationnumber) <> '';"); + AND up.identificationnumber IS NOT NULL AND btrim(up.identificationnumber) <> '' + AND NOT EXISTS ( + SELECT 1 FROM departmentdataprotectionpolicies p + WHERE p.departmentid = s.departmentid AND p.state <> 0);"); Execute.Sql(@" UPDATE departmentmembersensitivedata s @@ -47,7 +56,10 @@ UPDATE departmentmembersensitivedata s FROM userprofiles up INNER JOIN addresses ha ON ha.addressid = up.homeaddressid WHERE up.userid = s.userid - AND s.homeaddress1 IS NULL AND s.isprotected = false AND s.legacyprofilerelocatedon IS NULL;"); + AND s.homeaddress1 IS NULL AND s.isprotected = false AND s.legacyprofilerelocatedon IS NULL + AND NOT EXISTS ( + SELECT 1 FROM departmentdataprotectionpolicies p + WHERE p.departmentid = s.departmentid AND p.state <> 0);"); Execute.Sql(@" UPDATE departmentmembersensitivedata s @@ -56,13 +68,21 @@ UPDATE departmentmembersensitivedata s FROM userprofiles up INNER JOIN addresses ma ON ma.addressid = up.mailingaddressid WHERE up.userid = s.userid - AND s.mailingaddress1 IS NULL AND s.isprotected = false AND s.legacyprofilerelocatedon IS NULL;"); + AND s.mailingaddress1 IS NULL AND s.isprotected = false AND s.legacyprofilerelocatedon IS NULL + AND NOT EXISTS ( + SELECT 1 FROM departmentdataprotectionpolicies p + WHERE p.departmentid = s.departmentid AND p.state <> 0);"); - // 3) Stamp what moved, including rows whose member had nothing to move. + // 3) Stamp what moved in unprotected departments only, including rows whose member had + // nothing to move. An enrolled department's rows stay unstamped so the relocation + // service still owns them. Execute.Sql(@" -UPDATE departmentmembersensitivedata +UPDATE departmentmembersensitivedata s SET legacyprofilerelocatedon = (now() at time zone 'utc') -WHERE legacyprofilerelocatedon IS NULL AND isprotected = false;"); +WHERE s.legacyprofilerelocatedon IS NULL AND s.isprotected = false + AND NOT EXISTS ( + SELECT 1 FROM departmentdataprotectionpolicies p + WHERE p.departmentid = s.departmentid AND p.state <> 0);"); } public override void Down() diff --git a/Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberSensitiveDataRepository.cs b/Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberSensitiveDataRepository.cs index bd0c42004..1df6d3681 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberSensitiveDataRepository.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DepartmentMemberSensitiveDataRepository.cs @@ -18,6 +18,7 @@ public class DepartmentMemberSensitiveDataRepository : RepositoryBase> GetAllByDepartmentIdAsyn public Task> GetDepartmentIdsWithOutstandingLegacyProfileDataAsync() { + // Every table is schema-qualified: _table already carries the configured schema, so + // leaving the joins bare would only work while the connection's default schema happens + // to match. + // // "Outstanding" is the ABSENCE of the relocation marker, not an empty target column: a // member who cleared their department identification number has an empty target and must // not be swept forever. A member with no row at all is outstanding by definition. var sql = _isPostgres ? $@"SELECT DISTINCT dm.departmentid -FROM departmentmembers dm -INNER JOIN userprofiles up ON up.userid = dm.userid +FROM {_schema}.departmentmembers dm +INNER JOIN {_schema}.userprofiles up ON up.userid = dm.userid LEFT JOIN {_table} s ON s.departmentid = dm.departmentid AND s.userid = dm.userid WHERE dm.isdeleted = false AND s.legacyprofilerelocatedon IS NULL AND (up.homeaddressid IS NOT NULL OR up.mailingaddressid IS NOT NULL OR (up.identificationnumber IS NOT NULL AND btrim(up.identificationnumber) <> ''))" : $@"SELECT DISTINCT dm.[DepartmentId] -FROM [DepartmentMembers] dm -INNER JOIN [UserProfiles] up ON up.[UserId] = dm.[UserId] +FROM {_schema}.[DepartmentMembers] dm +INNER JOIN {_schema}.[UserProfiles] up ON up.[UserId] = dm.[UserId] LEFT JOIN {_table} s ON s.[DepartmentId] = dm.[DepartmentId] AND s.[UserId] = dm.[UserId] WHERE dm.[IsDeleted] = 0 AND s.[LegacyProfileRelocatedOn] IS NULL diff --git a/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs b/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs index fbcfc68f3..e13dc64a7 100644 --- a/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs @@ -455,6 +455,37 @@ public async Task Enforcement_stays_off_while_enrollment_encrypts() } } + /// + /// Every failure leaves envelopes at rest — enrollment and catalog upgrade stop part-way + /// through a sweep, rotation fails over an already-enveloped corpus, and offboarding fails + /// with the decrypt pass incomplete. ShouldEncryptNewWritesAsync keeps encrypting in this + /// state for everything but offboarding, so reads must enforce or the two disagree: writes + /// producing envelopes that reads hand straight to clients. + /// + [Test] + public async Task Enforcement_stays_on_after_any_failed_run() + { + foreach (var kind in new[] + { + DepartmentDataProtectionMigrationKind.Enrollment, + DepartmentDataProtectionMigrationKind.CatalogUpgrade, + DepartmentDataProtectionMigrationKind.Rotation, + DepartmentDataProtectionMigrationKind.Offboarding + }) + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(new DepartmentDataProtectionPolicy + { + DepartmentDataProtectionPolicyId = 1, + DepartmentId = DeptId, + State = (int)DepartmentDataProtectionState.Failed, + ActiveMigrationKind = (int)kind + }); + + (await _service.IsProtectionEnforcedAsync(DeptId)).Should().BeTrue( + $"a failed {kind} run can leave envelopes at rest, and serving those is unrecoverable"); + } + } + #endregion } } diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index 7cf46a9e9..03233b3ce 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -741,6 +741,42 @@ public async Task Call_log_write_envelopes_the_narrative() log.Narrative.Should().StartWith("rgdp:"); } + [Test] + public async Task Certification_sentinel_restore_reports_changed_so_the_caller_re_persists() + { + // The restore mutates the entity without producing a broker slot, so nothing else would + // set Changed. CertificationService re-saves only on Changed, so without this the row + // keeps the literal REDACTED placeholder and the member's licence number is gone. + SetupWriteEnforced(); + SetupEncryptEcho(); + SetupCurrentCatalogVersion(); + + var stored = new PersonnelCertification + { + PersonnelCertificationId = 21, + DepartmentId = DeptId, + Name = "rgdp:1:1:stored-name==", + Number = "rgdp:1:1:stored-number==" + }; + + // Every cataloged field comes back as the sentinel, so there is nothing left to encrypt. + var edited = new PersonnelCertification + { + PersonnelCertificationId = 21, + DepartmentId = DeptId, + Name = ProtectedDataEnvelope.RedactionValue, + Number = ProtectedDataEnvelope.RedactionValue + }; + + var result = await _service.PrepareCertificationWriteAsync(DeptId, edited, stored, IssueGrant(), UserId, + workloadCaller: false); + + result.Success.Should().BeTrue(); + result.Changed.Should().BeTrue("a restore with no encryption still has to be persisted"); + edited.Name.Should().Be("rgdp:1:1:stored-name=="); + edited.Number.Should().Be("rgdp:1:1:stored-number=="); + } + private void SetupCurrentCatalogVersion() { _dataProtectionService.Setup(x => x.GetPolicyByDepartmentIdAsync(DeptId, It.IsAny())) diff --git a/Web/Resgrid.Web.Services/Controllers/v4/UserDefinedFieldsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/UserDefinedFieldsController.cs index e158e0293..9e4ccec32 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/UserDefinedFieldsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/UserDefinedFieldsController.cs @@ -269,6 +269,12 @@ public async Task> GetSchemaForEntity(int entityTy : new List(); var values = allValues.Where(v => visibleFieldIds.Contains(v.UdfFieldId)).ToList(); + // Resolved like GetFieldValues above. The renderer SafeDisplays whatever it is handed, so + // without this a caller holding a grant still gets placeholders on mobile while the + // values endpoint gives them the real thing — same data, two answers. + await _protectedReadService.ResolveUdfFieldValuesForReadAsync(DepartmentId, values, + Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId, cancellationToken); + var schema = _renderingService.GenerateReactNativeSchema(definition, fields, values); return Ok(new UdfSchemaResult { Data = schema }); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index 70f6f344f..0f1df69cb 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -497,12 +497,26 @@ await _protectedReadService.ResolveMemberSensitiveDataForReadAsync(DepartmentId, // The department-scoped copies are independent rows, so "same as physical" is a value // comparison rather than the legacy shared-address-id check. + // + // It can only be computed from values that were actually revealed. Without a grant + // every protected component reads as the SAME placeholder, so two different + // addresses compare equal, the checkbox comes back ticked, and a later save copies + // the physical address over the member's real mailing address — losing a value the + // editor was never allowed to see. Two placeholders prove nothing about equality. + bool Revealed(string value) => + !string.IsNullOrEmpty(value) && value != ProtectedDataEnvelope.RedactionValue; + + bool SameComponent(string mailing, string home) => + string.Equals(mailing ?? string.Empty, home ?? string.Empty, StringComparison.OrdinalIgnoreCase) && + (Revealed(mailing) || string.IsNullOrEmpty(mailing)); + model.MailingAddressSameAsPhysical = + Revealed(memberAddresses.MailingAddress1) && string.Equals(memberAddresses.MailingAddress1, memberAddresses.HomeAddress1, StringComparison.OrdinalIgnoreCase) && - string.Equals(memberAddresses.MailingCity ?? string.Empty, memberAddresses.HomeCity ?? string.Empty, StringComparison.OrdinalIgnoreCase) && - string.Equals(memberAddresses.MailingState ?? string.Empty, memberAddresses.HomeState ?? string.Empty, StringComparison.OrdinalIgnoreCase) && - string.Equals(memberAddresses.MailingPostalCode ?? string.Empty, memberAddresses.HomePostalCode ?? string.Empty, StringComparison.OrdinalIgnoreCase) && - string.Equals(memberAddresses.MailingCountry ?? string.Empty, memberAddresses.HomeCountry ?? string.Empty, StringComparison.OrdinalIgnoreCase); + SameComponent(memberAddresses.MailingCity, memberAddresses.HomeCity) && + SameComponent(memberAddresses.MailingState, memberAddresses.HomeState) && + SameComponent(memberAddresses.MailingPostalCode, memberAddresses.HomePostalCode) && + SameComponent(memberAddresses.MailingCountry, memberAddresses.HomeCountry); } else if (legacyAddressFallbackAllowed && model.Profile != null && model.Profile.MailingAddressId.HasValue) { 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 index d4d6f3ca5..9e80142ac 100644 --- 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 @@ -25,11 +25,11 @@ var resgrid; // the server returns. English fallbacks keep the UI readable if a key is missing. '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.', + '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_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.' }; From b69e0f7a194a5fbd659cd1e5c2d9a793dc2b8c3e Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sun, 30 Aug 2026 08:24:09 -0700 Subject: [PATCH 3/4] RG-T89 ADP Third Sweep --- .../Handlers/PollCreateHandler.cs | 2 +- .../Services/TextResponseResolver.cs | 6 +- .../DataProtection/DataProtection.ar.resx | 15 + .../DataProtection/DataProtection.de.resx | 15 + .../DataProtection/DataProtection.el.resx | 15 + .../DataProtection/DataProtection.en.resx | 15 + .../DataProtection/DataProtection.es.resx | 15 + .../DataProtection/DataProtection.fr.resx | 15 + .../DataProtection/DataProtection.it.resx | 15 + .../DataProtection/DataProtection.pl.resx | 15 + .../DataProtection/DataProtection.sv.resx | 15 + .../DataProtection/DataProtection.uk.resx | 15 + .../SystemMessages/SystemMessages.ar.resx | 18 + .../SystemMessages/SystemMessages.de.resx | 18 + .../SystemMessages/SystemMessages.el.resx | 18 + .../SystemMessages/SystemMessages.en.resx | 18 + .../SystemMessages/SystemMessages.es.resx | 18 + .../SystemMessages/SystemMessages.fr.resx | 18 + .../SystemMessages/SystemMessages.it.resx | 18 + .../SystemMessages/SystemMessages.pl.resx | 18 + .../SystemMessages/SystemMessages.sv.resx | 18 + .../SystemMessages/SystemMessages.uk.resx | 18 + Core/Resgrid.Localization/Common.ar.resx | 24 + Core/Resgrid.Localization/Common.de.resx | 24 + Core/Resgrid.Localization/Common.el.resx | 24 + Core/Resgrid.Localization/Common.en.resx | 24 + Core/Resgrid.Localization/Common.es.resx | 24 + Core/Resgrid.Localization/Common.fr.resx | 24 + Core/Resgrid.Localization/Common.it.resx | 24 + Core/Resgrid.Localization/Common.pl.resx | 24 + Core/Resgrid.Localization/Common.sv.resx | 24 + Core/Resgrid.Localization/Common.uk.resx | 24 + Core/Resgrid.Model/AdpAddonBillingEvent.cs | 78 + Core/Resgrid.Model/AdpMigrationProgress.cs | 45 + Core/Resgrid.Model/CalendarItem.cs | 4 + Core/Resgrid.Model/Chat/ChatModeration.cs | 9 + Core/Resgrid.Model/CqrsEventTypes.cs | 7 + .../DepartmentDataProtectionPolicy.cs | 8 + Core/Resgrid.Model/DistributionList.cs | 4 + Core/Resgrid.Model/Document.cs | 4 + Core/Resgrid.Model/Message.cs | 11 + Core/Resgrid.Model/MessageRecipient.cs | 22 + Core/Resgrid.Model/Moderation/Moderation.cs | 9 + .../IDepartmentDataProtectionService.cs | 19 + .../Services/IProtectedProjectionService.cs | 23 +- .../Services/IProtectedReadService.cs | 92 ++ .../Services/IProtectedWriteService.cs | 62 + Core/Resgrid.Model/UnitLog.cs | 4 + Core/Resgrid.Model/UserProfile.cs | 10 +- Core/Resgrid.Model/UserState.cs | 4 + Core/Resgrid.Services/AdpTableBindings.cs | 110 +- .../Resgrid.Services/CalendarExportService.cs | 24 +- Core/Resgrid.Services/CalendarService.cs | 23 +- Core/Resgrid.Services/CallsService.cs | 12 +- Core/Resgrid.Services/CertificationService.cs | 33 +- .../Resgrid.Services/ChatModerationService.cs | 44 +- Core/Resgrid.Services/CommunicationService.cs | 52 +- Core/Resgrid.Services/DeleteService.cs | 4 + .../DepartmentDataProtectionService.cs | 175 ++- ...DepartmentMemberEmergencyContactService.cs | 33 +- .../DepartmentMemberSensitiveDataService.cs | 33 +- .../DistributionListsService.cs | 23 +- Core/Resgrid.Services/DocumentsService.cs | 26 +- Core/Resgrid.Services/MessageService.cs | 89 +- Core/Resgrid.Services/ModerationService.cs | 57 +- .../Resgrid.Services/ProtectedFieldCatalog.cs | 151 ++ .../ProtectedProjectionService.cs | 80 +- Core/Resgrid.Services/ProtectedReadService.cs | 1276 +++++++++++++++-- .../TextResponsePromptService.cs | 20 +- Core/Resgrid.Services/UdfRenderingService.cs | 11 +- Core/Resgrid.Services/UnitsService.cs | 17 +- Core/Resgrid.Services/UserStateService.cs | 35 +- .../ChatbotProviderModule.cs | 5 + .../ProtectedChatbotOutboundDecorator.cs | 56 + .../M0126_SeedAdpFeatureFlagAndAddon.cs | 5 +- .../M0136_UpdateAdpAddonStripePrice.cs | 38 + .../M0137_AddMessageDepartmentOwnership.cs | 190 +++ ...M0138_AddMessageRecipientPromptMetadata.cs | 55 + .../M0139_AddModerationProtectionMarkers.cs | 52 + .../M0140_AddRemainingProtectionMarkers.cs | 49 + .../M0141_ContractLegacyMemberProfileData.cs | 81 ++ .../M0142_AddPolicyLastBillingEventId.cs | 29 + .../M0126_SeedAdpFeatureFlagAndAddonPg.cs | 5 +- .../M0136_UpdateAdpAddonStripePricePg.cs | 38 + .../M0137_AddMessageDepartmentOwnershipPg.cs | 248 ++++ ...138_AddMessageRecipientPromptMetadataPg.cs | 55 + .../M0139_AddModerationProtectionMarkersPg.cs | 51 + .../M0140_AddRemainingProtectionMarkersPg.cs | 48 + ...M0141_ContractLegacyMemberProfileDataPg.cs | 84 ++ .../M0142_AddPolicyLastBillingEventIdPg.cs | 29 + Tests/Resgrid.Tests/Bootstrapper.cs | 4 + .../ChatbotTextResponseResolverTests.cs | 22 +- .../AdpAddonBillingReconciliationTests.cs | 191 +++ .../Services/AdpSizingServiceTests.cs | 6 +- .../Services/CalendarExportProtectionTests.cs | 77 + .../Services/CalendarServiceCheckInTests.cs | 18 +- .../CalendarServiceRsvpTransactionTests.cs | 19 +- .../Services/CalendarServiceTests.cs | 18 +- .../Services/ChatModerationServiceTests.cs | 33 +- .../Services/CommunicationServiceTests.cs | 9 +- .../DepartmentDataProtectionServiceTests.cs | 52 +- .../MemberEmergencyContactProtectionTests.cs | 73 +- .../MessageDepartmentOwnershipTests.cs | 158 ++ .../Services/MessageProtectionTests.cs | 111 ++ .../Services/MessageServiceInboxTests.cs | 24 +- .../Services/ModerationProtectionTests.cs | 146 ++ .../Services/ModerationServiceTests.cs | 32 +- .../Services/ProtectedReadServiceTests.cs | 192 ++- .../RemainingCandidateProtectionTests.cs | 141 ++ .../Web/Services/MessagesControllerTests.cs | 20 +- .../Web/Services/ModerationControllerTests.cs | 24 +- .../Web/User/ContactEditPersistenceTests.cs | 99 ++ .../User/ProtectedRevealAuthorizationTests.cs | 176 +++ .../Controllers/v4/CalendarController.cs | 23 +- .../Controllers/v4/MessagesController.cs | 34 +- .../Controllers/v4/ModerationController.cs | 47 +- .../v4/PersonnelStaffingController.cs | 18 +- .../Resgrid.Web.Services.xml | 34 + .../moderation/ModerationRequestsTable.tsx | 9 + .../Areas/User/Apps/src/runtime/api.ts | 9 + .../User/Controllers/CalendarController.cs | 42 +- .../User/Controllers/ContactsController.cs | 119 +- .../Controllers/DataProtectionController.cs | 14 + .../User/Controllers/DispatchController.cs | 13 +- .../User/Controllers/DocumentsController.cs | 62 +- .../Areas/User/Controllers/HomeController.cs | 86 +- .../User/Controllers/MessagesController.cs | 52 +- .../User/Controllers/PersonnelController.cs | 37 +- .../User/Controllers/ReportsController.cs | 4 +- .../Areas/User/Controllers/UnitsController.cs | 77 +- .../Areas/User/Models/AdpRevealView.cs | 33 + .../User/Models/Calendar/EditCalendarEntry.cs | 3 + .../Areas/User/Models/Calls/UpdateCallView.cs | 4 + .../User/Models/Contacts/EditContactView.cs | 3 + .../User/Models/Documents/ViewDocumentView.cs | 3 + .../Areas/User/Models/EditProfileModel.cs | 7 + .../User/Models/Messages/ViewMessageView.cs | 3 + .../User/Models/Personnel/ViewPersonView.cs | 3 + .../Areas/User/Models/Units/NewUnitView.cs | 3 + .../Areas/User/Models/Units/ViewLogsView.cs | 3 + .../Areas/User/Views/Calendar/Edit.cshtml | 29 +- .../Areas/User/Views/Contacts/Edit.cshtml | 48 +- .../Areas/User/Views/Contacts/View.cshtml | 70 +- .../User/Views/DataProtection/Index.cshtml | 28 +- .../User/Views/Dispatch/UpdateCall.cshtml | 40 +- .../Areas/User/Views/Dispatch/ViewCall.cshtml | 71 +- .../User/Views/Documents/ViewDocument.cshtml | 52 +- .../User/Views/Home/EditUserProfile.cshtml | 68 +- .../User/Views/Messages/ViewMessage.cshtml | 32 +- .../Areas/User/Views/Moderation/Index.cshtml | 33 + .../User/Views/Personnel/ViewPerson.cshtml | 27 +- .../User/Views/Shared/_AdpRevealBanner.cshtml | 12 + .../Views/Shared/_AdpRevealScripts.cshtml | 70 + .../Areas/User/Views/Units/EditUnit.cshtml | 26 + .../Areas/User/Views/Units/ViewLogs.cshtml | 27 +- .../Helpers/ProtectedUdfRevealHelper.cs | 55 + .../dataprotection/resgrid.adp.reveal.js | 70 +- .../resgrid.dataprotection.wizard.js | 52 + .../profile/resgrid.emergencycontacts.js | 21 +- .../Logic/CalendarNotifierLogic.cs | 22 +- .../Logic/ChatExportLogic.cs | 19 + .../Logic/PaymentQueueLogic.cs | 23 + 162 files changed, 7247 insertions(+), 477 deletions(-) create mode 100644 Core/Resgrid.Model/AdpAddonBillingEvent.cs create mode 100644 Core/Resgrid.Model/AdpMigrationProgress.cs create mode 100644 Providers/Resgrid.Providers.Chatbot/Services/ProtectedChatbotOutboundDecorator.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0136_UpdateAdpAddonStripePrice.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0137_AddMessageDepartmentOwnership.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0138_AddMessageRecipientPromptMetadata.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0139_AddModerationProtectionMarkers.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0140_AddRemainingProtectionMarkers.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0141_ContractLegacyMemberProfileData.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0142_AddPolicyLastBillingEventId.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0136_UpdateAdpAddonStripePricePg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0137_AddMessageDepartmentOwnershipPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0138_AddMessageRecipientPromptMetadataPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0139_AddModerationProtectionMarkersPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0140_AddRemainingProtectionMarkersPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0141_ContractLegacyMemberProfileDataPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0142_AddPolicyLastBillingEventIdPg.cs create mode 100644 Tests/Resgrid.Tests/Services/AdpAddonBillingReconciliationTests.cs create mode 100644 Tests/Resgrid.Tests/Services/CalendarExportProtectionTests.cs create mode 100644 Tests/Resgrid.Tests/Services/MessageDepartmentOwnershipTests.cs create mode 100644 Tests/Resgrid.Tests/Services/MessageProtectionTests.cs create mode 100644 Tests/Resgrid.Tests/Services/ModerationProtectionTests.cs create mode 100644 Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/ContactEditPersistenceTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/ProtectedRevealAuthorizationTests.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/AdpRevealView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealBanner.cshtml create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shared/_AdpRevealScripts.cshtml create mode 100644 Web/Resgrid.Web/Helpers/ProtectedUdfRevealHelper.cs diff --git a/Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs b/Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs index 39e721501..a76455520 100644 --- a/Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs @@ -96,7 +96,7 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn foreach (var userId in recipients) { msg.AddRecipient(userId); - msg.MessageRecipients.Last().Note = TextResponsePromptMetadata.ForPoll(session.DepartmentId); + msg.MessageRecipients.Last().PromptMetadata = TextResponsePromptMetadata.ForPoll(session.DepartmentId); } var saved = await _messageService.SaveMessageAsync(msg); diff --git a/Core/Resgrid.Chatbot/Services/TextResponseResolver.cs b/Core/Resgrid.Chatbot/Services/TextResponseResolver.cs index 137eaf3ec..ada69e5b0 100644 --- a/Core/Resgrid.Chatbot/Services/TextResponseResolver.cs +++ b/Core/Resgrid.Chatbot/Services/TextResponseResolver.cs @@ -53,7 +53,7 @@ public async Task> GetPendingResponsesAsync(s if (message.Type == (int)MessageTypes.Poll) { - if (!TextResponsePromptMetadata.TryGetPollDepartmentId(recipient.Note, out var pollDepartmentId) + if (!TextResponsePromptMetadata.TryGetPollDepartmentId(recipient.PromptMetadata, out var pollDepartmentId) || pollDepartmentId != departmentId) continue; @@ -67,7 +67,7 @@ public async Task> GetPendingResponsesAsync(s continue; } - if (!TextResponsePromptMetadata.TryGetCalendarItemId(recipient.Note, out var calendarItemId) + if (!TextResponsePromptMetadata.TryGetCalendarItemId(recipient.PromptMetadata, out var calendarItemId) || !seenCalendarItems.Add(calendarItemId)) continue; @@ -102,7 +102,7 @@ public async Task RecordResponseAsync(PendingTextResponse targe if (target.Type == PendingTextResponseType.Poll) { - if (!TextResponsePromptMetadata.TryGetPollDepartmentId(recipient.Note, out var pollDepartmentId) + if (!TextResponsePromptMetadata.TryGetPollDepartmentId(recipient.PromptMetadata, out var pollDepartmentId) || pollDepartmentId != session.DepartmentId) return null; diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.ar.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.ar.resx index 74bbdfc8e..83d62e92f 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.ar.resx @@ -100,6 +100,21 @@ إلغاء التسجيل في قائمة الانتظار + + تقدّم الترحيل + + + تمت معالجة {0} من {1} صفًا + + + تجري المعالجة حاليًا: {0}. + + + لم يتم إحصاء أي صفوف بعد — لم تبدأ النافذة الليلية الأولى. + + + تعذّرت معالجة {0} صفًا وتحتاج إلى انتباه. + الترحيل قيد التنفيذ. يجري العمل فقط داخل نافذتك الليلية ({0}–{1}، {2})؛ وخارجها تعمل إدارتك بكامل الخدمة. diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.de.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.de.resx index 83fc3eeb6..d8f7a5efa 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.de.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.de.resx @@ -100,6 +100,21 @@ Eingereihte Anmeldung abbrechen + + Migrationsfortschritt + + + {0} von {1} Zeilen verarbeitet + + + Aktuell wird {0} verarbeitet. + + + Es wurden noch keine Zeilen gezählt – das erste Nachtfenster hat noch nicht begonnen. + + + {0} Zeilen konnten nicht verarbeitet werden und erfordern Aufmerksamkeit. + Die Migration läuft. Die Arbeit findet nur innerhalb Ihres Nachtfensters statt ({0}–{1}, {2}); außerhalb ist Ihre Abteilung voll einsatzfähig. diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.el.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.el.resx index 827765ee4..8b556476d 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.el.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.el.resx @@ -100,6 +100,21 @@ Ακύρωση εγγραφής σε ουρά + + Πρόοδος μετεγκατάστασης + + + Επεξεργάστηκαν {0} από {1} εγγραφές + + + Αυτήν τη στιγμή γίνεται επεξεργασία: {0}. + + + Δεν έχουν καταμετρηθεί ακόμη εγγραφές — το πρώτο νυχτερινό παράθυρο δεν έχει ξεκινήσει. + + + {0} εγγραφές δεν ήταν δυνατό να επεξεργαστούν και χρειάζονται προσοχή. + Η μετεγκατάσταση βρίσκεται σε εξέλιξη. Η εργασία γίνεται μόνο εντός του νυχτερινού παραθύρου ({0}–{1}, {2})· εκτός αυτού το τμήμα σας λειτουργεί πλήρως. diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.en.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.en.resx index 33acb68f3..81bb1dc53 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.en.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.en.resx @@ -100,6 +100,21 @@ Cancel queued enrollment + + Migration progress + + + {0} of {1} rows processed + + + Currently working through {0}. + + + No rows have been counted yet - the first overnight window has not started. + + + {0} rows could not be processed and need attention. + Migration is in progress. Work happens only inside your overnight window ({0}–{1}, {2}); your department is in full service outside it. diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.es.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.es.resx index 509735765..ae446754d 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.es.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.es.resx @@ -100,6 +100,21 @@ Cancelar la inscripción en cola + + Progreso de la migración + + + {0} de {1} filas procesadas + + + Procesando actualmente {0}. + + + Todavía no se ha contado ninguna fila: la primera ventana nocturna aún no ha comenzado. + + + {0} filas no se pudieron procesar y requieren atención. + La migración está en curso. El trabajo ocurre solo dentro de su ventana nocturna ({0}–{1}, {2}); fuera de ella su departamento está en pleno servicio. diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.fr.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.fr.resx index 27564161e..c865080c3 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.fr.resx @@ -100,6 +100,21 @@ Annuler l'inscription en file d'attente + + Progression de la migration + + + {0} sur {1} lignes traitées + + + Traitement en cours de {0}. + + + Aucune ligne n'a encore été comptée : la première fenêtre nocturne n'a pas commencé. + + + {0} lignes n'ont pas pu être traitées et nécessitent votre attention. + La migration est en cours. Le travail n'a lieu que dans votre fenêtre nocturne ({0}–{1}, {2}) ; en dehors, votre département est pleinement opérationnel. diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.it.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.it.resx index 790ab853a..d630ec6c9 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.it.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.it.resx @@ -100,6 +100,21 @@ Annulla l'iscrizione in coda + + Avanzamento della migrazione + + + {0} di {1} righe elaborate + + + Attualmente in elaborazione: {0}. + + + Nessuna riga è stata ancora conteggiata: la prima finestra notturna non è iniziata. + + + {0} righe non sono state elaborate e richiedono attenzione. + La migrazione è in corso. Il lavoro avviene solo nella finestra notturna ({0}–{1}, {2}); al di fuori il tuo dipartimento è in pieno servizio. diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.pl.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.pl.resx index f620cb927..361cd589d 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.pl.resx @@ -100,6 +100,21 @@ Anuluj rejestrację w kolejce + + Postęp migracji + + + Przetworzono {0} z {1} wierszy + + + Obecnie przetwarzane: {0}. + + + Nie policzono jeszcze żadnych wierszy — pierwsze okno nocne jeszcze się nie rozpoczęło. + + + {0} wierszy nie udało się przetworzyć i wymagają uwagi. + Migracja jest w toku. Prace odbywają się tylko w oknie nocnym ({0}–{1}, {2}); poza nim departament działa w pełni. diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.sv.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.sv.resx index 850d357b5..954b40c85 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.sv.resx @@ -100,6 +100,21 @@ Avbryt köad registrering + + Migreringsförlopp + + + {0} av {1} rader bearbetade + + + Bearbetar just nu {0}. + + + Inga rader har räknats ännu – det första nattfönstret har inte börjat. + + + {0} rader kunde inte bearbetas och kräver åtgärd. + Migreringen pågår. Arbetet sker endast inom nattfönstret ({0}–{1}, {2}); utanför det är organisationen i full drift. diff --git a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.uk.resx b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.uk.resx index bc75d8e96..1711f1686 100644 --- a/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/DataProtection/DataProtection.uk.resx @@ -100,6 +100,21 @@ Скасувати реєстрацію в черзі + + Хід міграції + + + Оброблено {0} з {1} рядків + + + Зараз обробляється: {0}. + + + Рядки ще не підраховані — перше нічне вікно ще не почалося. + + + {0} рядків не вдалося обробити, потрібна увага. + Міграція триває. Робота відбувається лише у нічному вікні ({0}–{1}, {2}); поза ним підрозділ працює у повному обсязі. diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx index b2892dc39..98be35e26 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.ar.resx @@ -90,6 +90,24 @@ لعرض هذه القيم، سجّل الدخول إلى Resgrid وافتح الصفحة التي تنتمي إليها، حيث يمكنك إظهارها بعد تأكيد هويتك. كما يمكن لمسؤول إدارتك تزويدك بها. + + رسالة محمية + + + في انتظارك رسالة محمية. سجّل الدخول إلى Resgrid لقراءتها. + + + إدخال تقويم محمي + + + موقع محمي + + + يتوفر بلاغ محمي. سجّل الدخول إلى Resgrid لعرض التفاصيل. + + + بلاغ محمي + تصدير بياناتك من Resgrid جاهز diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx index 2221acca9..aa6159b3f 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.de.resx @@ -90,6 +90,24 @@ Ihr Resgrid-Team Um diese Werte zu sehen, melden Sie sich bei Resgrid an und öffnen Sie die zugehörige Seite. Dort können Sie sie nach einer Identitätsbestätigung anzeigen. Ihre Abteilungsadministration kann sie Ihnen ebenfalls zur Verfügung stellen. + + Geschützte Nachricht + + + Eine geschützte Nachricht wartet auf Sie. Melden Sie sich bei Resgrid an, um sie zu lesen. + + + Geschützter Kalendereintrag + + + einem geschützten Ort + + + Ein geschützter Einsatz liegt vor. Melden Sie sich bei Resgrid an, um die Details zu sehen. + + + Geschützter Einsatz + Ihr Resgrid-Datenexport ist bereit diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx index e6bcf615e..05f90b1b8 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.el.resx @@ -90,6 +90,24 @@ Για να δείτε αυτές τις τιμές, συνδεθείτε στο Resgrid και ανοίξτε τη σελίδα στην οποία ανήκουν, όπου μπορείτε να τις εμφανίσετε αφού επιβεβαιώσετε την ταυτότητά σας. Μπορεί επίσης να σας τις δώσει ο διαχειριστής της υπηρεσίας σας. + + Προστατευμένο μήνυμα + + + Σας περιμένει ένα προστατευμένο μήνυμα. Συνδεθείτε στο Resgrid για να το διαβάσετε. + + + Προστατευμένη καταχώριση ημερολογίου + + + προστατευμένη τοποθεσία + + + Υπάρχει διαθέσιμη προστατευμένη κλήση. Συνδεθείτε στο Resgrid για να δείτε τις λεπτομέρειες. + + + Προστατευμένη κλήση + Η εξαγωγή δεδομένων σας από το Resgrid είναι έτοιμη diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx index f554ee51f..df185793e 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.en.resx @@ -90,6 +90,24 @@ The Resgrid Team To see these values, sign in to Resgrid and open the page they belong to, where you can reveal them after confirming your identity. Your department administrator can also provide them. + + Protected message + + + A protected message is waiting for you. Sign in to Resgrid to read it. + + + Protected calendar entry + + + a protected location + + + A protected dispatch is available. Sign in to Resgrid to view details. + + + Protected dispatch + Your Resgrid Data Export is Ready diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx index 5388e2071..cb30dbea3 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.es.resx @@ -90,6 +90,24 @@ El equipo de Resgrid Para ver estos valores, inicie sesión en Resgrid y abra la página a la que pertenecen, donde podrá mostrarlos tras confirmar su identidad. La administración de su departamento también puede facilitárselos. + + Mensaje protegido + + + Tiene un mensaje protegido esperando. Inicie sesión en Resgrid para leerlo. + + + Entrada de calendario protegida + + + un lugar protegido + + + Hay un despacho protegido disponible. Inicie sesión en Resgrid para ver los detalles. + + + Despacho protegido + Su exportación de datos de Resgrid está lista diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx index 61d4f27db..fd7376873 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.fr.resx @@ -90,6 +90,24 @@ L'équipe Resgrid Pour consulter ces valeurs, connectez-vous à Resgrid et ouvrez la page correspondante, où vous pourrez les afficher après confirmation de votre identité. L'administrateur de votre service peut également vous les communiquer. + + Message protégé + + + Un message protégé vous attend. Connectez-vous à Resgrid pour le lire. + + + Entrée de calendrier protégée + + + un lieu protégé + + + Une intervention protégée est disponible. Connectez-vous à Resgrid pour en voir les détails. + + + Intervention protégée + Votre export de données Resgrid est prêt diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx index 15852db9b..47d1f7455 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.it.resx @@ -90,6 +90,24 @@ Il team Resgrid Per vedere questi valori, accedi a Resgrid e apri la pagina a cui appartengono, dove puoi mostrarli dopo aver confermato la tua identità. Anche l'amministratore del dipartimento può fornirteli. + + Messaggio protetto + + + Ti attende un messaggio protetto. Accedi a Resgrid per leggerlo. + + + Voce di calendario protetta + + + un luogo protetto + + + È disponibile un intervento protetto. Accedi a Resgrid per vederne i dettagli. + + + Intervento protetto + La tua esportazione dati Resgrid è pronta diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx index b5a9e1dad..01a1d43b5 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.pl.resx @@ -90,6 +90,24 @@ Zespół Resgrid Aby zobaczyć te wartości, zaloguj się do Resgrid i otwórz stronę, do której należą — możesz je tam wyświetlić po potwierdzeniu tożsamości. Może je również udostępnić administrator Twojej jednostki. + + Chroniona wiadomość + + + Czeka na Ciebie chroniona wiadomość. Zaloguj się do Resgrid, aby ją przeczytać. + + + Chroniony wpis kalendarza + + + chronionej lokalizacji + + + Dostępne jest chronione zgłoszenie. Zaloguj się do Resgrid, aby zobaczyć szczegóły. + + + Chronione zgłoszenie + Twój eksport danych Resgrid jest gotowy diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx index ffc73ed3f..a9024ce01 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.sv.resx @@ -90,6 +90,24 @@ Resgrid-teamet Logga in i Resgrid och öppna sidan där värdena hör hemma för att visa dem efter att du bekräftat din identitet. Din organisationsadministratör kan också lämna ut dem. + + Skyddat meddelande + + + Ett skyddat meddelande väntar på dig. Logga in i Resgrid för att läsa det. + + + Skyddad kalenderpost + + + en skyddad plats + + + Ett skyddat larm finns tillgängligt. Logga in i Resgrid för att se detaljerna. + + + Skyddat larm + Din Resgrid-dataexport är klar diff --git a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx index 77dc384db..d4ea62ffd 100644 --- a/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/SystemMessages/SystemMessages.uk.resx @@ -90,6 +90,24 @@ Щоб побачити ці значення, увійдіть у Resgrid і відкрийте сторінку, до якої вони належать, — там ви зможете показати їх після підтвердження особи. Їх також може надати адміністратор вашого підрозділу. + + Захищене повідомлення + + + На вас чекає захищене повідомлення. Увійдіть у Resgrid, щоб прочитати його. + + + Захищений запис календаря + + + захищеному місці + + + Доступний захищений виклик. Увійдіть у Resgrid, щоб переглянути деталі. + + + Захищений виклик + Ваш експорт даних Resgrid готовий diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx index 8e2a172ab..dda00c747 100644 --- a/Core/Resgrid.Localization/Common.ar.resx +++ b/Core/Resgrid.Localization/Common.ar.resx @@ -208,6 +208,30 @@ جهة اتصال محمية + + سجل أفراد محمي + + + سجل وحدة محمي + + + ملف شخصي محمي + + + رسالة محمية + + + مستند محمي + + + إدخال تقويم محمي + + + قائمة إشراف محمية + + + سجلات وحدة محمية + مشفّر أثناء التخزين لهذه الإدارة. لا يزال بإمكان المستخدمين المصرح لهم والقنوات المعتمدة الكشف عنه. diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx index b8217b7ac..5a8e9b4df 100644 --- a/Core/Resgrid.Localization/Common.de.resx +++ b/Core/Resgrid.Localization/Common.de.resx @@ -641,6 +641,30 @@ Geschützter Kontakt + + Geschützter Personaldatensatz + + + Geschützter Einheitendatensatz + + + Geschütztes Profil + + + Geschützte Nachricht + + + Geschütztes Dokument + + + Geschützter Kalendereintrag + + + Geschützte Moderationswarteschlange + + + Geschützte Einheitenprotokolle + für diese Abteilung im Ruhezustand verschlüsselt. Berechtigte Benutzer und freigegebene Kanäle können sie dennoch offenlegen. diff --git a/Core/Resgrid.Localization/Common.el.resx b/Core/Resgrid.Localization/Common.el.resx index d7c79ed61..55aff0166 100644 --- a/Core/Resgrid.Localization/Common.el.resx +++ b/Core/Resgrid.Localization/Common.el.resx @@ -693,6 +693,30 @@ Προστατευμένη επαφή + + Προστατευμένη εγγραφή προσωπικού + + + Προστατευμένη εγγραφή μονάδας + + + Προστατευμένο προφίλ + + + Προστατευμένο μήνυμα + + + Προστατευμένο έγγραφο + + + Προστατευμένη καταχώριση ημερολογίου + + + Προστατευμένη ουρά συντονισμού + + + Προστατευμένα αρχεία καταγραφής μονάδας + κρυπτογραφημένο σε ηρεμία για αυτό το τμήμα. Οι εξουσιοδοτημένοι χρήστες και τα εγκεκριμένα κανάλια μπορούν παρ’ όλα αυτά να το αποκαλύψουν. diff --git a/Core/Resgrid.Localization/Common.en.resx b/Core/Resgrid.Localization/Common.en.resx index 036d249e0..437d537ee 100644 --- a/Core/Resgrid.Localization/Common.en.resx +++ b/Core/Resgrid.Localization/Common.en.resx @@ -693,6 +693,30 @@ Protected contact + + Protected personnel record + + + Protected unit record + + + Protected profile + + + Protected message + + + Protected document + + + Protected calendar entry + + + Protected moderation queue + + + Protected unit logs + encrypted at rest for this department. Authorized users and approved channels may still disclose it. diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx index 477b04e98..89e5899aa 100644 --- a/Core/Resgrid.Localization/Common.es.resx +++ b/Core/Resgrid.Localization/Common.es.resx @@ -681,6 +681,30 @@ Contacto protegido + + Registro de personal protegido + + + Registro de unidad protegido + + + Perfil protegido + + + Mensaje protegido + + + Documento protegido + + + Entrada de calendario protegida + + + Cola de moderación protegida + + + Registros de unidad protegidos + cifrado en reposo para este departamento. Los usuarios autorizados y los canales aprobados aún pueden divulgarlo. diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx index 6db576e73..424ba1010 100644 --- a/Core/Resgrid.Localization/Common.fr.resx +++ b/Core/Resgrid.Localization/Common.fr.resx @@ -641,6 +641,30 @@ Contact protégé + + Dossier du personnel protégé + + + Fiche d'unité protégée + + + Profil protégé + + + Message protégé + + + Document protégé + + + Entrée de calendrier protégée + + + File de modération protégée + + + Journaux d'unité protégés + chiffré au repos pour ce département. Les utilisateurs autorisés et les canaux approuvés peuvent tout de même le divulguer. diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx index ccdad14e6..9066101fb 100644 --- a/Core/Resgrid.Localization/Common.it.resx +++ b/Core/Resgrid.Localization/Common.it.resx @@ -641,6 +641,30 @@ Contatto protetto + + Scheda personale protetta + + + Scheda unità protetta + + + Profilo protetto + + + Messaggio protetto + + + Documento protetto + + + Voce di calendario protetta + + + Coda di moderazione protetta + + + Registri unità protetti + crittografato a riposo per questo dipartimento. Gli utenti autorizzati e i canali approvati possono comunque divulgarlo. diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx index 4e80ff153..065c1fb99 100644 --- a/Core/Resgrid.Localization/Common.pl.resx +++ b/Core/Resgrid.Localization/Common.pl.resx @@ -641,6 +641,30 @@ Kontakt chroniony + + Chroniony rekord personelu + + + Chroniony rekord jednostki + + + Chroniony profil + + + Chroniona wiadomość + + + Chroniony dokument + + + Chroniony wpis kalendarza + + + Chroniona kolejka moderacji + + + Chronione dzienniki jednostki + zaszyfrowane w spoczynku dla tego departamentu. Uprawnieni użytkownicy i zatwierdzone kanały nadal mogą je ujawnić. diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx index 3f3509f30..b993645b9 100644 --- a/Core/Resgrid.Localization/Common.sv.resx +++ b/Core/Resgrid.Localization/Common.sv.resx @@ -641,6 +641,30 @@ Skyddad kontakt + + Skyddad personalpost + + + Skyddad enhetspost + + + Skyddad profil + + + Skyddat meddelande + + + Skyddat dokument + + + Skyddad kalenderpost + + + Skyddad modereringskö + + + Skyddade enhetsloggar + krypterat i vila för denna organisation. Behöriga användare och godkända kanaler kan ändå röja det. diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx index 1dbef717f..a5e4edeeb 100644 --- a/Core/Resgrid.Localization/Common.uk.resx +++ b/Core/Resgrid.Localization/Common.uk.resx @@ -641,6 +641,30 @@ Захищений контакт + + Захищений запис особового складу + + + Захищений запис підрозділу + + + Захищений профіль + + + Захищене повідомлення + + + Захищений документ + + + Захищений запис календаря + + + Захищена черга модерації + + + Захищені журнали підрозділу + зашифровано в стані спокою для цього підрозділу. Авторизовані користувачі та схвалені канали все одно можуть його розкрити. diff --git a/Core/Resgrid.Model/AdpAddonBillingEvent.cs b/Core/Resgrid.Model/AdpAddonBillingEvent.cs new file mode 100644 index 000000000..38800972c --- /dev/null +++ b/Core/Resgrid.Model/AdpAddonBillingEvent.cs @@ -0,0 +1,78 @@ +using System; + +namespace Resgrid.Model +{ + /// What happened to a department's ADP addon in the billing provider. + public enum AdpAddonBillingEventKind + { + /// First purchase, or a repurchase after a lapse. Makes the department wizard-eligible. + Activated = 1, + + /// The yearly cycle renewed; extends the paid-through date and nothing else. + Renewed = 2, + + /// + /// Cancelled in the provider. Protection continues to the end of the paid cycle — this + /// SCHEDULES offboarding for that instant, it never decrypts anything. + /// + Cancelled = 3, + + /// + /// A payment failed and the provider's dunning window is running. Protection continues + /// untouched; exhausted dunning arrives later as . + /// + PaymentFailed = 4 + } + + /// + /// A typed, idempotent domain event describing an ADP addon change, emitted by the Billing API + /// and applied in Core (ADP plan section 17.2). The department is resolved on the billing side, + /// where the provider customer id maps to a department — Core never sees a provider customer. + /// + /// Billing truth and data-safety truth stay separate (plan decision 14): NOTHING in this event + /// disables decryption, suppresses grants or downgrades clients. It can only move the durable + /// lifecycle state; only a completed offboarding migration changes ciphertext. + /// + public class AdpAddonBillingEvent + { + public int DepartmentId { get; set; } + + public AdpAddonBillingEventKind Kind { get; set; } + + /// + /// The provider's subscription identifier. Recorded on the policy so an operator can tie a + /// department's protection state back to the subscription that pays for it. + /// + public string ExternalSubscriptionRef { get; set; } + + /// End of the paid cycle currently in force (Activated/Renewed). + public DateTime? PaidThroughUtc { get; set; } + + /// + /// When protection should end (Cancelled). The end of the paid cycle for an ordinary + /// cancellation; now, for a chargeback or refund — but even then offboarding runs through + /// the normal worker path rather than flipping anything instantly. + /// + public DateTime? EffectiveEndUtc { get; set; } + + /// Provider dunning descriptor, carried for the audit line only (PaymentFailed). + public string DunningState { get; set; } + + /// True when this cancellation came from a chargeback or refund rather than the member. + public bool IsChargeback { get; set; } + + /// True when the cancellation is the end of an exhausted dunning cycle. + public bool IsDunningExhausted { get; set; } + + public DateTime OccurredOnUtc { get; set; } + + /// + /// The provider's event id. Providers retry and duplicate webhooks, so this is what lets the + /// handler prove it has already applied this exact event. + /// + public string ProviderEventId { get; set; } + + /// Which provider this came from, for the audit line. + public string ProviderName { get; set; } + } +} diff --git a/Core/Resgrid.Model/AdpMigrationProgress.cs b/Core/Resgrid.Model/AdpMigrationProgress.cs new file mode 100644 index 000000000..0c0e48ada --- /dev/null +++ b/Core/Resgrid.Model/AdpMigrationProgress.cs @@ -0,0 +1,45 @@ +namespace Resgrid.Model +{ + /// + /// What the enrollment wizard's status panel shows while a department is being encrypted, + /// decrypted or rotated (plan 18). Value-free by construction: row counts and table NAMES only, + /// never a value out of any of them. + /// + /// The numbers come from the same cursor rows the migration engine writes, so the panel and the + /// worker agree by construction rather than by a second estimate. + /// + public class AdpMigrationProgress + { + /// True when a run of any kind currently has cursor rows in flight. + public bool IsRunning { get; set; } + + /// The kind of run in flight (enrollment, offboarding, rotation, catalog upgrade). + public DepartmentDataProtectionMigrationKind Kind { get; set; } + + public string KindName => Kind.ToString(); + + /// + /// Rows done over rows known, 0-100, or null when nothing has been counted yet. Tables the + /// run has not opened yet have no cursor row, so early in a run this UNDERSTATES progress — + /// it is the same figure the engine reports, deliberately, so the two never disagree. + /// + public int? PercentComplete { get; set; } + + public long RowsTotal { get; set; } + + /// Rows the run has written plus rows it found already protected. + public long RowsCompleted { get; set; } + + /// + /// Rows that could not be handled (a foreign envelope, a decrypt that did not + /// authenticate). Non-zero means the run halted or needs an operator. + /// + public long RowsAnomalous { get; set; } + + /// How many tables the run has opened a cursor for. + public int TablesStarted { get; set; } + + /// The table with the least-finished cursor — what the run is working through. + public string CurrentTable { get; set; } + } +} diff --git a/Core/Resgrid.Model/CalendarItem.cs b/Core/Resgrid.Model/CalendarItem.cs index 02e7340b7..945c7ceb1 100644 --- a/Core/Resgrid.Model/CalendarItem.cs +++ b/Core/Resgrid.Model/CalendarItem.cs @@ -107,6 +107,10 @@ public object IdValue } + + /// ADP row marker (M0140): true once this row's cataloged values carry rgdp envelopes. + public bool IsProtected { get; set; } + [NotMapped] public string TableName => "CalendarItems"; diff --git a/Core/Resgrid.Model/Chat/ChatModeration.cs b/Core/Resgrid.Model/Chat/ChatModeration.cs index 98c336d24..9cad62300 100644 --- a/Core/Resgrid.Model/Chat/ChatModeration.cs +++ b/Core/Resgrid.Model/Chat/ChatModeration.cs @@ -34,6 +34,9 @@ public class ChatMessageFlag : IEntity public string ResolutionNote { get; set; } + /// ADP row marker (M0139): true once this row's cataloged values carry rgdp envelopes. + public bool IsProtected { get; set; } + [NotMapped] public string TableName => "ChatMessageFlags"; @@ -84,6 +87,9 @@ public class ChatModerationAction : IEntity public string DetailsJson { get; set; } + /// ADP row marker (M0139): true once this row's cataloged values carry rgdp envelopes. + public bool IsProtected { get; set; } + [NotMapped] public string TableName => "ChatModerationActions"; @@ -185,6 +191,9 @@ public class ChatExport : IEntity public string Error { get; set; } + /// ADP row marker (M0139): true once this row's cataloged values carry rgdp envelopes. + public bool IsProtected { get; set; } + [NotMapped] public string TableName => "ChatExports"; diff --git a/Core/Resgrid.Model/CqrsEventTypes.cs b/Core/Resgrid.Model/CqrsEventTypes.cs index ba806ceb5..ac158cc07 100644 --- a/Core/Resgrid.Model/CqrsEventTypes.cs +++ b/Core/Resgrid.Model/CqrsEventTypes.cs @@ -25,5 +25,12 @@ public enum CqrsEventTypes PaddleSubscriptionCanceled = 20, PaddleSubscriptionCreated = 21, IncidentCommandUpdated = 22, + + // ADP addon billing events (plan 17.2). Emitted by the Billing API with the department + // already resolved, applied in Core by DepartmentDataProtectionService. + AdpAddonActivated = 23, + AdpAddonRenewed = 24, + AdpAddonCancelled = 25, + AdpAddonPaymentFailed = 26, } } diff --git a/Core/Resgrid.Model/DepartmentDataProtectionPolicy.cs b/Core/Resgrid.Model/DepartmentDataProtectionPolicy.cs index ba44f4125..e4a839014 100644 --- a/Core/Resgrid.Model/DepartmentDataProtectionPolicy.cs +++ b/Core/Resgrid.Model/DepartmentDataProtectionPolicy.cs @@ -93,6 +93,14 @@ public class DepartmentDataProtectionPolicy : IEntity [ProtoMember(14)] public string AddonBillingReference { get; set; } + /// + /// The provider event id of the last ADP billing event applied (M0142). Payment providers + /// retry and duplicate webhooks, so this is what lets the handler recognise an event it has + /// already acted on and refuse to act twice. + /// + [ProtoMember(24)] + public string LastBillingEventId { get; set; } + /// Department-local overnight migration window start, "HH:mm" (default 22:00). [MaxLength(5)] [ProtoMember(15)] diff --git a/Core/Resgrid.Model/DistributionList.cs b/Core/Resgrid.Model/DistributionList.cs index e69a21c51..1a9c1c37d 100644 --- a/Core/Resgrid.Model/DistributionList.cs +++ b/Core/Resgrid.Model/DistributionList.cs @@ -69,6 +69,10 @@ public object IdValue } + + /// ADP row marker (M0140): true once this row's cataloged values carry rgdp envelopes. + public bool IsProtected { get; set; } + [NotMapped] public string TableName => "DistributionLists"; diff --git a/Core/Resgrid.Model/Document.cs b/Core/Resgrid.Model/Document.cs index 68f9f4707..d59251291 100644 --- a/Core/Resgrid.Model/Document.cs +++ b/Core/Resgrid.Model/Document.cs @@ -54,6 +54,10 @@ public object IdValue set { DocumentId = (int)value; } } + + /// ADP row marker (M0140): true once this row's cataloged values carry rgdp envelopes. + public bool IsProtected { get; set; } + [NotMapped] public string TableName => "Documents"; diff --git a/Core/Resgrid.Model/Message.cs b/Core/Resgrid.Model/Message.cs index 4dba9202e..36933d0fa 100644 --- a/Core/Resgrid.Model/Message.cs +++ b/Core/Resgrid.Model/Message.cs @@ -71,6 +71,17 @@ public class Message : IEntity [ProtoMember(14)] public virtual ICollection MessageRecipients { get; set; } + /// + /// The department that owns this row (M0137). Messages are addressed to users, and a user + /// can belong to several departments and move between them, so ownership is resolved ONCE + /// at send time and frozen here rather than derived through a join: the ADP envelope AAD + /// binds the department, and a derived value that later moves would orphan every envelope + /// written under it. Null on historic rows the M0137 backfill could not attribute, which + /// keeps them out of encryption instead of encrypting them under a guess. + /// + [ProtoMember(15)] + public int? DepartmentId { get; set; } + /// /// Optional subtitle for push/inbox notifications; not persisted. When set, delivery /// uses this instead of the generic "Msg from ..." subtitle. diff --git a/Core/Resgrid.Model/MessageRecipient.cs b/Core/Resgrid.Model/MessageRecipient.cs index 98dd972ca..5db644666 100644 --- a/Core/Resgrid.Model/MessageRecipient.cs +++ b/Core/Resgrid.Model/MessageRecipient.cs @@ -66,6 +66,28 @@ public class MessageRecipient : IEntity [ProtoMember(13)] public string ProtectedLongitudeEnvelope { get; set; } + /// + /// The department that owns this row (M0137). Messages are addressed to users, and a user + /// can belong to several departments and move between them, so ownership is resolved ONCE + /// at send time and frozen here rather than derived through a join: the ADP envelope AAD + /// binds the department, and a derived value that later moves would orphan every envelope + /// written under it. Null on historic rows the M0137 backfill could not attribute, which + /// keeps them out of encryption instead of encrypting them under a guess. + /// + [ProtoMember(14)] + public int? DepartmentId { get; set; } + + /// + /// Machine metadata for prompts that can be answered from any text channel — which calendar + /// item or poll this recipient row belongs to (M0138). It used to share the Note column with + /// the member's own words, which is what kept Note out of the protected-field catalog: every + /// reader of this token runs WITHOUT a Protected Data Grant (the chatbot inbound resolver + /// most of all), and the broker's workload lane cannot decrypt. Deliberately NOT cataloged: + /// it is a row pointer and says nothing about a person. + /// + [ProtoMember(15)] + public string PromptMetadata { get; set; } + [NotMapped] [JsonIgnore] public object IdValue diff --git a/Core/Resgrid.Model/Moderation/Moderation.cs b/Core/Resgrid.Model/Moderation/Moderation.cs index 8d6b11aff..88bd8de31 100644 --- a/Core/Resgrid.Model/Moderation/Moderation.cs +++ b/Core/Resgrid.Model/Moderation/Moderation.cs @@ -78,6 +78,9 @@ public class ModerationRequest : IEntity public DateTime? CompletedOn { get; set; } public string AdminNote { get; set; } + /// ADP row marker (M0139): true once this row's cataloged values carry rgdp envelopes. + public bool IsProtected { get; set; } + [NotMapped] public List Reports { get; set; } = new List(); @@ -120,6 +123,9 @@ public class ModerationReport : IEntity public string Note { get; set; } public DateTime ReportedOn { get; set; } + /// ADP row marker (M0139): true once this row's cataloged values carry rgdp envelopes. + public bool IsProtected { get; set; } + [NotMapped] public string TableName => "ModerationReports"; @@ -169,6 +175,9 @@ public class ModerationAction : IEntity public string EvidenceMetadataJson { get; set; } + /// ADP row marker (M0139): true once this row's cataloged values carry rgdp envelopes. + public bool IsProtected { get; set; } + [NotMapped] public string TableName => "ModerationActions"; diff --git a/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs b/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs index 5ed754d56..8abb1f2fb 100644 --- a/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentDataProtectionService.cs @@ -107,6 +107,25 @@ Task SaveEgressPolicyAsync(DepartmentProtec Task GetEnrollmentPreflightAsync(int departmentId, string requestingUserId, CancellationToken cancellationToken = default); + /// + /// Row-count progress for the department's in-flight migration, for the wizard status panel + /// (plan 18). Reads the SAME cursor rows the engine writes, so the panel cannot disagree + /// with the worker. Returns a not-running report when nothing is in flight. + /// + Task GetMigrationProgressAsync(int departmentId, + CancellationToken cancellationToken = default); + + /// + /// Applies an ADP addon billing event to the department's durable protection state + /// (plan 17.3). Idempotent: providers retry and duplicate webhooks, and an out-of-order + /// Cancelled-then-Renewed pair must settle on the provider's current truth. + /// + /// This can only move lifecycle state. It never disables decryption, suppresses grants or + /// downgrades clients — only the completed offboarding migration changes ciphertext. + /// + Task ApplyAddonBillingEventAsync(AdpAddonBillingEvent billingEvent, + CancellationToken cancellationToken = default); + /// Atomically bumps the department policy epoch (grant revocation); returns the new epoch. Task IncrementPolicyEpochAsync(int departmentId, string updatedByUserId, CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/Services/IProtectedProjectionService.cs b/Core/Resgrid.Model/Services/IProtectedProjectionService.cs index a8295a6db..77efc07e7 100644 --- a/Core/Resgrid.Model/Services/IProtectedProjectionService.cs +++ b/Core/Resgrid.Model/Services/IProtectedProjectionService.cs @@ -28,7 +28,28 @@ public interface IProtectedProjectionService /// "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); + /// + /// The safe view of a member message for one outbound channel (catalog v7). A protected + /// department's message body and subject are encrypted at rest, and notification hosts hold + /// no grant and no broker, so what they would otherwise hand a carrier is ciphertext. The + /// sanitized clone carries no content at all - the member is told a message is waiting and + /// reads it signed in. + /// + /// + /// The RECIPIENT's language; the clone carries text a member actually reads. Null falls back + /// to English. + /// + Task BuildNotificationSafeMessageAsync(int departmentId, Message message, + ProtectedDataEgressChannel channel, string culture = null); + + /// + /// The RECIPIENT's language. The sanitized clone carries text a member actually reads on + /// their handset, so it is localized like every other outbound string; the safe view is + /// built per recipient, so the caller passes the profile language it already has. Null + /// falls back to English — a unit device or a member with no language set. + /// + Task BuildNotificationSafeCallAsync(int departmentId, Call call, ProtectedDataEgressChannel channel, + string culture = null); ///
    /// True when this channel must receive only sanitized (generic) content for the department: diff --git a/Core/Resgrid.Model/Services/IProtectedReadService.cs b/Core/Resgrid.Model/Services/IProtectedReadService.cs index 5affe4001..d78560d07 100644 --- a/Core/Resgrid.Model/Services/IProtectedReadService.cs +++ b/Core/Resgrid.Model/Services/IProtectedReadService.cs @@ -64,6 +64,98 @@ Task ResolveCertificationsForReadAsync(int departmentId, IReadOnlyList certifications, string grantToken, string userId, bool includeData = false, CancellationToken cancellationToken = default); + /// + /// Resolves the notes on a call's linked-call references (callreferences.note). Written from + /// the linked-call editor and rendered back into it, so it needs the same treatment as any + /// other cataloged free text. + /// + Task ResolveCallReferencesForReadAsync(int departmentId, + IReadOnlyList references, string grantToken, string userId, + CancellationToken cancellationToken = default); + + /// + /// Resolves member messages (messages.subject/body, catalog v7). Populated MessageRecipients + /// ride the same broker batch, so an inbox read reveals the conversation in one round trip. + /// + Task ResolveMessagesForReadAsync(int departmentId, + IReadOnlyList messages, string grantToken, string userId, + CancellationToken cancellationToken = default); + + /// + /// Resolves standalone message-recipient rows (response/note plus the companion coordinates + /// a reply was filed from). + /// + Task ResolveMessageRecipientsForReadAsync(int departmentId, + IReadOnlyList recipients, string grantToken, string userId, + CancellationToken cancellationToken = default); + + /// + /// Resolves moderation requests (the reported subject, text, file name/type, metadata and the + /// moderator's admin note). The reported FILE rides along only when includeContent is set; + /// a queue listing strips the bytes. + /// + Task ResolveModerationRequestsForReadAsync(int departmentId, + IReadOnlyList requests, string grantToken, string userId, + bool includeContent = false, CancellationToken cancellationToken = default); + + /// Resolves reporter notes on moderation reports. + Task ResolveModerationReportsForReadAsync(int departmentId, + IReadOnlyList reports, string grantToken, string userId, + CancellationToken cancellationToken = default); + + /// + /// Resolves moderation actions (note, details and the evidence snapshot). The actor/IP/ + /// user-agent audit columns are not cataloged and always read as themselves. + /// + Task ResolveModerationActionsForReadAsync(int departmentId, + IReadOnlyList actions, string grantToken, string userId, + bool includeContent = false, CancellationToken cancellationToken = default); + + /// Resolves chat message flag notes and resolution notes. + Task ResolveChatMessageFlagsForReadAsync(int departmentId, + IReadOnlyList flags, string grantToken, string userId, + CancellationToken cancellationToken = default); + + /// Resolves chat moderation action reasons and detail JSON. + Task ResolveChatModerationActionsForReadAsync(int departmentId, + IReadOnlyList actions, string grantToken, string userId, + CancellationToken cancellationToken = default); + + /// + /// Resolves chat export rows. The archive itself is an entire conversation, so it decrypts + /// only when includeData is set; a status listing strips it. + /// + Task ResolveChatExportsForReadAsync(int departmentId, + IReadOnlyList exports, string grantToken, string userId, + bool includeData = false, CancellationToken cancellationToken = default); + + /// Resolves unit log narratives (unitlogs.narrative). + Task ResolveUnitLogsForReadAsync(int departmentId, IReadOnlyList logs, + string grantToken, string userId, CancellationToken cancellationToken = default); + + /// Resolves user state notes (userstates.note). + Task ResolveUserStatesForReadAsync(int departmentId, IReadOnlyList states, + string grantToken, string userId, CancellationToken cancellationToken = default); + + /// + /// Resolves calendar item title/description/location. The scheduling columns are structural + /// and never encrypted, so a protected department's calendar still lays out without a grant. + /// + Task ResolveCalendarItemsForReadAsync(int departmentId, IReadOnlyList items, + string grantToken, string userId, CancellationToken cancellationToken = default); + + /// + /// Resolves department documents. The file itself decrypts only when includeData is set; a + /// listing strips the bytes rather than handing back ciphertext. + /// + Task ResolveDocumentsForReadAsync(int departmentId, IReadOnlyList documents, + string grantToken, string userId, bool includeData = false, CancellationToken cancellationToken = default); + + /// Resolves stored distribution-list mailbox credentials (section 22.1). + Task ResolveDistributionListsForReadAsync(int departmentId, + IReadOnlyList lists, string grantToken, string userId, + CancellationToken cancellationToken = default); + /// Resolves standalone contact-note lists (contactnotes.note). Task ResolveContactNotesForReadAsync(int departmentId, IReadOnlyList notes, string grantToken, string userId, CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/Services/IProtectedWriteService.cs b/Core/Resgrid.Model/Services/IProtectedWriteService.cs index 4b547470a..7fa25558c 100644 --- a/Core/Resgrid.Model/Services/IProtectedWriteService.cs +++ b/Core/Resgrid.Model/Services/IProtectedWriteService.cs @@ -67,6 +67,68 @@ Task PrepareCertificationWriteAsync(int departmentId, PersonnelCertification certification, PersonnelCertification existingCertification, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + /// Prepares a linked-call reference note (callreferences.note). + Task PrepareCallReferenceWriteAsync(int departmentId, CallReference reference, + CallReference existingReference, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default); + + /// Prepares a member message (messages.subject/body). + Task PrepareMessageWriteAsync(int departmentId, Message message, + string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default); + + /// Prepares a message recipient row (response/note plus companion coordinates). + Task PrepareMessageRecipientWriteAsync(int departmentId, MessageRecipient recipient, + string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default); + + /// Prepares a moderation request (the verbatim copy of the reported content). + Task PrepareModerationRequestWriteAsync(int departmentId, ModerationRequest request, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares a reporter's note on a moderation report. + Task PrepareModerationReportWriteAsync(int departmentId, ModerationReport report, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares a moderation action (note, details, evidence snapshot). + Task PrepareModerationActionWriteAsync(int departmentId, ModerationAction action, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares a chat message flag (note, resolution note). + Task PrepareChatMessageFlagWriteAsync(int departmentId, ChatMessageFlag flag, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares a chat moderation action (reason, details). + Task PrepareChatModerationActionWriteAsync(int departmentId, ChatModerationAction action, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares a chat export row (the archive payload and any failure text). + Task PrepareChatExportWriteAsync(int departmentId, ChatExport export, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares a unit log narrative (unitlogs.narrative). + Task PrepareUnitLogWriteAsync(int departmentId, UnitLog log, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares a user state note (userstates.note). + Task PrepareUserStateWriteAsync(int departmentId, UserState state, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); + + /// Prepares a calendar item (title, description, location). + Task PrepareCalendarItemWriteAsync(int departmentId, CalendarItem item, + CalendarItem existingItem, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default); + + /// Prepares a department document (name, description, filename and the file). + Task PrepareDocumentWriteAsync(int departmentId, Document document, + Document existingDocument, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default); + + /// Prepares stored distribution-list mailbox credentials (section 22.1). + Task PrepareDistributionListWriteAsync(int departmentId, DistributionList list, + DistributionList existingList, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default); + /// Prepares a call log narrative (calllogs.narrative). Task PrepareCallLogWriteAsync(int departmentId, CallLog log, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/UnitLog.cs b/Core/Resgrid.Model/UnitLog.cs index 4190e7bc8..ce8a82906 100644 --- a/Core/Resgrid.Model/UnitLog.cs +++ b/Core/Resgrid.Model/UnitLog.cs @@ -35,6 +35,10 @@ public object IdValue } + + /// ADP row marker (M0140): true once this row's cataloged values carry rgdp envelopes. + public bool IsProtected { get; set; } + [NotMapped] public string TableName => "UnitLogs"; diff --git a/Core/Resgrid.Model/UserProfile.cs b/Core/Resgrid.Model/UserProfile.cs index b5ec04cf5..6c4f12493 100644 --- a/Core/Resgrid.Model/UserProfile.cs +++ b/Core/Resgrid.Model/UserProfile.cs @@ -80,6 +80,14 @@ public class UserProfile: IEntity //[ProtoMember(17)] public bool DoNotRecieveNewsletters { get; set; } + /// + /// GONE FROM THE SCHEMA (M0141). A profile is global to a person across every department + /// they belong to, so this could never be encrypted under one department's key; the value + /// lives on DepartmentMemberSensitiveData per department and is cataloged there. The + /// property is kept only so the ProtoMember numbering stays stable for older app builds + /// that still deserialize it, and is NotMapped/ignored so no SQL ever names the column. + /// + [NotMapped] [ProtoMember(18)] public string IdentificationNumber { get; set; } @@ -243,7 +251,7 @@ public object IdValue public int IdType => 0; [NotMapped] - public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "User", "MembershipEmail" }; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "User", "MembershipEmail", "IdentificationNumber" }; [NotMapped] public FullNameFormat FullName diff --git a/Core/Resgrid.Model/UserState.cs b/Core/Resgrid.Model/UserState.cs index abbe8f4d5..73d214877 100644 --- a/Core/Resgrid.Model/UserState.cs +++ b/Core/Resgrid.Model/UserState.cs @@ -47,6 +47,10 @@ public object IdValue set { UserStateId = (int)value; } } + + /// ADP row marker (M0140): true once this row's cataloged values carry rgdp envelopes. + public bool IsProtected { get; set; } + [NotMapped] public string TableName => "UserStates"; diff --git a/Core/Resgrid.Services/AdpTableBindings.cs b/Core/Resgrid.Services/AdpTableBindings.cs index b2c9cc437..afe1b8388 100644 --- a/Core/Resgrid.Services/AdpTableBindings.cs +++ b/Core/Resgrid.Services/AdpTableBindings.cs @@ -114,9 +114,8 @@ AdpColumnSpec Companion(string table, string column) => // Catalog v2 (section 5.2). Neither table carries its own DepartmentId, so ownership // derives from a verified parent: UDF values through their definition, unit states - // through the unit. MessageRecipients is deliberately ABSENT — Messages has no - // DepartmentId either, so it needs the section 5.1 child-table ownership migration - // before it can be bound at all. + // through the unit. (Messages and MessageRecipients were absent for the same reason + // until M0137 gave them a DepartmentId of their own; they are bound below at v7.) // Catalog v3: the incident log carries its own DepartmentId. AdpTableBinding.Direct("Logs", "LogId", pkIsNumeric: true, "DepartmentId", new[] { @@ -180,6 +179,111 @@ AdpColumnSpec Companion(string table, string column) => Text("PersonnelCertifications", "IssuedBy"), Text("PersonnelCertifications", "Filename"), Binary("PersonnelCertifications", "Data") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + // Catalog v7: member messaging. Both are Direct on their OWN DepartmentId (M0137) + // rather than ViaParent — a recipient row is scoped by the same column its parent + // is, and the AAD needs a value on the row itself. Rows the M0137 backfill could + // not attribute have a NULL DepartmentId and are therefore never selected by a + // department-scoped sweep: unresolved ownership means untouched, not guessed. + // + // Messages has no IsProtected marker column (M0129 added one to MessageRecipients + // only); reads detect the envelope prefix, exactly as they do for Contacts. + AdpTableBinding.Direct("Messages", "MessageId", pkIsNumeric: true, "DepartmentId", new[] + { + Text("Messages", "Subject"), + Text("Messages", "Body") + }), + + // Note is here only because M0138 moved the prompt metadata it used to share a column + // with into PromptMetadata (which stays plaintext for the grantless readers). + AdpTableBinding.Direct("MessageRecipients", "MessageRecipientId", pkIsNumeric: true, "DepartmentId", new[] + { + Text("MessageRecipients", "Response"), + Text("MessageRecipients", "Note"), + Companion("MessageRecipients", "Latitude"), + Companion("MessageRecipients", "Longitude") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + // Catalog v8: moderation (section 5.3). All six tables carry their own DepartmentId + // and a string primary key the service assigns before insert, so every row can be + // enveloped BEFORE it first reaches the table - no transient plaintext anywhere in + // this family. Markers come from M0139. + AdpTableBinding.Direct("ModerationRequests", "ModerationRequestId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("ModerationRequests", "OriginalSubject"), + Text("ModerationRequests", "OriginalText"), + Text("ModerationRequests", "OriginalFileName"), + Text("ModerationRequests", "OriginalContentType"), + Binary("ModerationRequests", "OriginalContent"), + Text("ModerationRequests", "OriginalMetadataJson"), + Text("ModerationRequests", "AdminNote") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("ModerationReports", "ModerationReportId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("ModerationReports", "Note") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("ModerationActions", "ModerationActionId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("ModerationActions", "Note"), + Text("ModerationActions", "DetailsJson"), + Text("ModerationActions", "EvidenceText"), + Binary("ModerationActions", "EvidenceContent"), + Text("ModerationActions", "EvidenceMetadataJson") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("ChatMessageFlags", "ChatMessageFlagId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("ChatMessageFlags", "Note"), + Text("ChatMessageFlags", "ResolutionNote") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("ChatModerationActions", "ChatModerationActionId", pkIsNumeric: false, "DepartmentId", new[] + { + Text("ChatModerationActions", "Reason"), + Text("ChatModerationActions", "DetailsJson") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("ChatExports", "ChatExportId", pkIsNumeric: false, "DepartmentId", new[] + { + Binary("ChatExports", "Data"), + Text("ChatExports", "Error") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + // Catalog v9: the plan's remaining candidates. UnitLogs has no DepartmentId of its + // own, so it derives ownership through its unit exactly as UnitStates does; the rest + // carry their own. Markers come from M0140. + AdpTableBinding.ViaParent("UnitLogs", "UnitLogId", pkIsNumeric: true, "UnitId", "Units", "UnitId", new[] + { + Text("UnitLogs", "Narrative") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("UserStates", "UserStateId", pkIsNumeric: true, "DepartmentId", new[] + { + Text("UserStates", "Note") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("CalendarItems", "CalendarItemId", pkIsNumeric: true, "DepartmentId", new[] + { + Text("CalendarItems", "Title"), + Text("CalendarItems", "Description"), + Text("CalendarItems", "Location") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("Documents", "DocumentId", pkIsNumeric: true, "DepartmentId", new[] + { + Text("Documents", "Name"), + Text("Documents", "Description"), + Text("Documents", "Filename"), + Binary("Documents", "Data") + }) with { ProtectedMarkerColumn = "IsProtected" }, + + AdpTableBinding.Direct("DistributionLists", "DistributionListId", pkIsNumeric: true, "DepartmentId", new[] + { + Text("DistributionLists", "Username"), + Text("DistributionLists", "Password") }) with { ProtectedMarkerColumn = "IsProtected" } }; } diff --git a/Core/Resgrid.Services/CalendarExportService.cs b/Core/Resgrid.Services/CalendarExportService.cs index 5411aea5d..b1e686797 100644 --- a/Core/Resgrid.Services/CalendarExportService.cs +++ b/Core/Resgrid.Services/CalendarExportService.cs @@ -3,6 +3,7 @@ using Ical.Net.DataTypes; using Ical.Net.Serialization; using Resgrid.Config; +using Resgrid.Localization.Areas.User.SystemMessages; using Resgrid.Model; using Resgrid.Model.Services; using System; @@ -67,14 +68,29 @@ private static Calendar CreateBaseCalendar() return calendar; } + /// + /// The iCal feed is fetched by a calendar application with a revocable token and NO Protected + /// Data Grant — there is no step-up available to it and never will be. So for a protected + /// department the entry keeps its shape (when it starts, how long, its reminder) and loses + /// its content: the member sees that something is scheduled and opens Resgrid to find out + /// what. This is the same rule the RSS feed follows for dispatches (plan 5.5). + /// + private static string SafeFeedText(string value, string generic) + => ProtectedDataEnvelope.HasEnvelopePrefix(value) || value == ProtectedDataEnvelope.RedactionValue + ? generic + : value; + private static CalendarEvent MapToCalendarEvent(CalendarItem item) { + var summary = SafeFeedText(item.Title, + SystemMessagesResources.Get("AdpProtectedCalendarTitle", null)); + var ev = new CalendarEvent { Uid = $"resgrid-cal-{item.CalendarItemId}@resgrid", - Summary = item.Title ?? string.Empty, - Description = StripHtml(item.Description), - Location = item.Location, + Summary = summary ?? string.Empty, + Description = StripHtml(SafeFeedText(item.Description, string.Empty)), + Location = SafeFeedText(item.Location, string.Empty), IsAllDay = item.IsAllDay }; @@ -102,7 +118,7 @@ private static CalendarEvent MapToCalendarEvent(CalendarItem item) var alarm = new Alarm { Action = AlarmAction.Display, - Description = item.Title ?? "Reminder", + Description = summary ?? "Reminder", Trigger = new Trigger(TimeSpan.FromMinutes(-reminderMinutes)) }; ev.Alarms.Add(alarm); diff --git a/Core/Resgrid.Services/CalendarService.cs b/Core/Resgrid.Services/CalendarService.cs index e87e3e26c..4c46bc8cb 100644 --- a/Core/Resgrid.Services/CalendarService.cs +++ b/Core/Resgrid.Services/CalendarService.cs @@ -17,6 +17,7 @@ namespace Resgrid.Services public class CalendarService : ICalendarService { private readonly ICalendarItemsRepository _calendarItemRepository; + private readonly Lazy _protectedWriteService; private readonly ICalendarItemTypeRepository _calendarItemTypeRepository; private readonly ICalendarItemAttendeeRepository _calendarItemAttendeeRepository; private readonly IDepartmentsService _departmentsService; @@ -35,8 +36,10 @@ public CalendarService(ICalendarItemsRepository calendarItemRepository, ICalenda IUserProfileService userProfileService, IDepartmentGroupsService departmentGroupsService, IDepartmentSettingsService departmentSettingsService, IEncryptionService encryptionService, ICalendarItemCheckInRepository calendarItemCheckInRepository, IMessageRecipientRepository messageRecipientRepository, IUnitOfWork unitOfWork, + Lazy protectedWriteService, ITextResponsePromptService textResponsePromptService = null) { + _protectedWriteService = protectedWriteService; _calendarItemRepository = calendarItemRepository; _calendarItemTypeRepository = calendarItemTypeRepository; _calendarItemAttendeeRepository = calendarItemAttendeeRepository; @@ -90,7 +93,25 @@ public async Task> GetUpcomingCalendarItemsAsync(int departme public async Task SaveCalendarItemAsync(CalendarItem calendarItem, CancellationToken cancellationToken = default(CancellationToken)) { - return await _calendarItemRepository.SaveOrUpdateAsync(calendarItem, cancellationToken); + // The stored row backs REDACTED-sentinel restoration: an editor without a grant posts + // placeholders back from the form, and those must not overwrite the real values. + CalendarItem existing = null; + if (calendarItem != null && calendarItem.CalendarItemId > 0) + existing = await _calendarItemRepository.GetCalendarItemByIdAsync(calendarItem.CalendarItemId); + + var saved = await _calendarItemRepository.SaveOrUpdateAsync(calendarItem, cancellationToken); + + // ADP write safety net (plan 4.2/19.2, catalog v9). Runs AFTER the save because the AAD + // row key is the identity pk, then re-persists the enveloped row. Fails closed by + // throwing rather than leaving the value in plaintext. + var protectedWrite = await _protectedWriteService.Value.PrepareCalendarItemWriteAsync(saved.DepartmentId, + saved, existing, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); calendar item {saved.CalendarItemId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _calendarItemRepository.SaveOrUpdateAsync(saved, cancellationToken); + + return saved; } public async Task GetCalendarItemByIdAsync(int calendarItemId) diff --git a/Core/Resgrid.Services/CallsService.cs b/Core/Resgrid.Services/CallsService.cs index 3add0a6e7..fea3560c9 100644 --- a/Core/Resgrid.Services/CallsService.cs +++ b/Core/Resgrid.Services/CallsService.cs @@ -223,7 +223,17 @@ public CallsService(ICallsRepository callsRepository, ICommunicationService comm { reference.SourceCallId = savedCall.CallId; - await _callReferencesRepository.SaveOrUpdateAsync(reference, cancellationToken); + var savedReference = await _callReferencesRepository.SaveOrUpdateAsync(reference, cancellationToken); + + // ADP write safety net: callreferences.note is cataloged, and the linked-call + // editor posts it back from a hidden input — so without this the note is stored + // in plaintext, or the REDACTED placeholder overwrites it. + var referenceWrite = await _protectedWriteService.Value.PrepareCallReferenceWriteAsync( + savedCall.DepartmentId, savedReference, null, null, null, workloadCaller: true, cancellationToken); + if (!referenceWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({referenceWrite.Reason}); call reference {savedReference.CallReferenceId} has transient plaintext pending re-encryption."); + if (referenceWrite.Changed) + await _callReferencesRepository.SaveOrUpdateAsync(savedReference, cancellationToken); } } diff --git a/Core/Resgrid.Services/CertificationService.cs b/Core/Resgrid.Services/CertificationService.cs index 404e3bf52..bc27be5a9 100644 --- a/Core/Resgrid.Services/CertificationService.cs +++ b/Core/Resgrid.Services/CertificationService.cs @@ -99,17 +99,32 @@ public async Task> GetDepartmentCertificationTypesAsync(int departm if (certification != null && certification.PersonnelCertificationId > 0) existing = await _personnelCertificationRepository.GetByIdAsync(certification.PersonnelCertificationId); + // ADP write safety net (plan 4.2/19.2). The AAD row key is the identity pk, so a NEW row + // must be inserted before it can be enveloped, and that insert is a transient plaintext + // write. An UPDATE already has its id, so it is enveloped BEFORE the save and no + // plaintext ever reaches the table - which is the common path here, since a member + // edits this data far more often than they first fill it in. Fails closed either way. + var isExistingRow = certification != null && certification.PersonnelCertificationId > 0; + + if (isExistingRow) + { + var preSaveWrite = await _protectedWriteService.Value.PrepareCertificationWriteAsync( + certification.DepartmentId, certification, existing, null, null, workloadCaller: true, cancellationToken); + if (!preSaveWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({preSaveWrite.Reason}); certification {certification.PersonnelCertificationId} was NOT saved."); + } + var saved = await _personnelCertificationRepository.SaveOrUpdateAsync(certification, cancellationToken); - // ADP write safety net (plan 4.2/19.2). Runs AFTER the save so the identity pk exists — - // it is the AAD row key — then re-persists the enveloped row. Fails closed by throwing - // rather than leaving a member's certification number and document in plaintext. - var protectedWrite = await _protectedWriteService.Value.PrepareCertificationWriteAsync( - saved.DepartmentId, saved, existing, null, null, workloadCaller: true, cancellationToken); - if (!protectedWrite.Success) - throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); certification {saved.PersonnelCertificationId} has transient plaintext pending re-encryption."); - if (protectedWrite.Changed) - saved = await _personnelCertificationRepository.SaveOrUpdateAsync(saved, cancellationToken); + if (!isExistingRow) + { + var protectedWrite = await _protectedWriteService.Value.PrepareCertificationWriteAsync( + saved.DepartmentId, saved, existing, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); certification {saved.PersonnelCertificationId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _personnelCertificationRepository.SaveOrUpdateAsync(saved, cancellationToken); + } return saved; } diff --git a/Core/Resgrid.Services/ChatModerationService.cs b/Core/Resgrid.Services/ChatModerationService.cs index ef09d13e5..c8a2bd90c 100644 --- a/Core/Resgrid.Services/ChatModerationService.cs +++ b/Core/Resgrid.Services/ChatModerationService.cs @@ -29,12 +29,14 @@ public class ChatModerationService : IChatModerationService private readonly IChatChannelService _chatChannelService; private readonly IChatPermissionService _chatPermissionService; private readonly IAuditService _auditService; + private readonly Lazy _protectedWriteService; private readonly IEventAggregator _eventAggregator; public ChatModerationService(IChatMessageFlagRepository chatMessageFlagRepository, IChatModerationActionRepository chatModerationActionRepository, IChatExportRepository chatExportRepository, IChatChannelRepository chatChannelRepository, IChatChannelMemberRepository chatChannelMemberRepository, IChatMessageRepository chatMessageRepository, IChatMessageService chatMessageService, IChatChannelService chatChannelService, - IChatPermissionService chatPermissionService, IAuditService auditService, IEventAggregator eventAggregator) + IChatPermissionService chatPermissionService, IAuditService auditService, IEventAggregator eventAggregator, + Lazy protectedWriteService) { _chatMessageFlagRepository = chatMessageFlagRepository; _chatModerationActionRepository = chatModerationActionRepository; @@ -46,6 +48,7 @@ public ChatModerationService(IChatMessageFlagRepository chatMessageFlagRepositor _chatChannelService = chatChannelService; _chatPermissionService = chatPermissionService; _auditService = auditService; + _protectedWriteService = protectedWriteService; _eventAggregator = eventAggregator; } @@ -65,7 +68,7 @@ public ChatModerationService(IChatMessageFlagRepository chatMessageFlagRepositor if (existing != null) return existing; - var flag = await _chatMessageFlagRepository.InsertAsync(new ChatMessageFlag + var newFlag = new ChatMessageFlag { ChatMessageFlagId = Guid.NewGuid().ToString(), ChatMessageId = chatMessageId, @@ -76,7 +79,17 @@ public ChatModerationService(IChatMessageFlagRepository chatMessageFlagRepositor Note = note, FlaggedOn = DateTime.UtcNow, Status = (int)ChatFlagStatus.Open - }, cancellationToken); + }; + + // ADP write safety net (plan 5.3, catalog v8). The id is assigned here rather than by the + // database, so the row is enveloped BEFORE the insert and no plaintext ever reaches the + // table. Fails closed: a flag that cannot be protected is not written. + await ProtectChatModerationWriteAsync( + () => _protectedWriteService.Value.PrepareChatMessageFlagWriteAsync(newFlag.DepartmentId, newFlag, + null, null, workloadCaller: true, cancellationToken), + $"chat message flag {newFlag.ChatMessageFlagId}"); + + var flag = await _chatMessageFlagRepository.InsertAsync(newFlag, cancellationToken); PublishModerationEvent(message.DepartmentId, message.ChatChannelId, new { Type = "flagged", flag.ChatMessageFlagId, chatMessageId }); @@ -229,6 +242,8 @@ public async Task> GetModerationActionsAsync(int depa return null; } + // Queued exports carry no payload yet; the worker runs the same net when it attaches the + // archive (ChatExportLogic). var export = await _chatExportRepository.InsertAsync(new ChatExport { ChatExportId = Guid.NewGuid().ToString(), @@ -308,11 +323,23 @@ private async Task GetModerationTargetMemberAsync(ChatChannel return member != null && member.DepartmentId == channel.DepartmentId ? member : null; } + /// + /// ADP write safety net (plan 5.3, catalog v8). Chat moderation rows quote the message that + /// was reported and the moderator's reasoning, so they are enveloped before they are written. + /// Fails closed by throwing rather than storing the quote in the clear. + /// + private static async Task ProtectChatModerationWriteAsync(Func> prepare, string description) + { + var result = await prepare(); + if (!result.Success) + throw new InvalidOperationException($"Protected write blocked ({result.Reason}); {description} was NOT saved."); + } + private async Task RecordActionAsync(int departmentId, string chatChannelId, string chatMessageId, string targetUserId, int? targetUnitId, ChatModerationActionType actionType, string byUserId, string reason, string detailsJson, AuditLogTypes auditLogType, CancellationToken cancellationToken, ChatModerationContext context = null) { - await _chatModerationActionRepository.InsertAsync(new ChatModerationAction + var action = new ChatModerationAction { ChatModerationActionId = Guid.NewGuid().ToString(), DepartmentId = departmentId, @@ -325,7 +352,14 @@ await _chatModerationActionRepository.InsertAsync(new ChatModerationAction PerformedOn = DateTime.UtcNow, Reason = reason, DetailsJson = detailsJson - }, cancellationToken); + }; + + await ProtectChatModerationWriteAsync( + () => _protectedWriteService.Value.PrepareChatModerationActionWriteAsync(departmentId, action, + null, null, workloadCaller: true, cancellationToken), + $"chat moderation action {action.ChatModerationActionId}"); + + await _chatModerationActionRepository.InsertAsync(action, cancellationToken); // Mirror to the department audit trail with full forensic context for SIEM ingestion. This // is only reached after the action succeeded, so result is always Success; a failed action diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index 027f495d9..70f4b78ad 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -57,6 +57,19 @@ public async Task SendMessageAsync(Message message, string sendersName, st if (profile == null && !String.IsNullOrWhiteSpace(message.ReceivingUserId)) profile = await _userProfileService.GetProfileByUserIdAsync(message.ReceivingUserId); + // ADP (catalog v7): messages.subject/body are encrypted at rest for a protected + // department, and this host has neither a grant nor the broker - so per channel it + // either sends the real message (unprotected, or a channel the department has opted + // into with plaintext still in the row) or a content-free notice telling the member to + // sign in. Resolved per channel because the department can allow content on one and + // not another. + var smsMessage = await _protectedProjectionService.BuildNotificationSafeMessageAsync( + departmentId, message, ProtectedDataEgressChannel.Sms, profile?.Language); + var emailMessage = await _protectedProjectionService.BuildNotificationSafeMessageAsync( + departmentId, message, ProtectedDataEgressChannel.Email, profile?.Language); + var pushMessage = await _protectedProjectionService.BuildNotificationSafeMessageAsync( + departmentId, message, ProtectedDataEgressChannel.Push, profile?.Language); + // Weather Alert Notifications are intentionally email/push-only. if (message.Type != (int)MessageTypes.WeatherAlert && (profile == null || (message.SystemGenerated ? profile.SendNotificationSms : profile.SendMessageSms))) @@ -66,7 +79,7 @@ public async Task SendMessageAsync(Message message, string sendersName, st try { var payment = await _subscriptionsService.GetCurrentPaymentForDepartmentAsync(departmentId); - await _smsService.SendMessageAsync(message, departmentNumber, departmentId, profile, payment); + await _smsService.SendMessageAsync(smsMessage, departmentNumber, departmentId, profile, payment); } catch (Exception ex) { @@ -81,7 +94,7 @@ public async Task SendMessageAsync(Message message, string sendersName, st { try { - await _emailService.SendMessageAsync(message, sendersName, departmentId, profile, message.ReceivingUser); + await _emailService.SendMessageAsync(emailMessage, sendersName, departmentId, profile, message.ReceivingUser); } catch (Exception ex) { @@ -101,7 +114,7 @@ public async Task SendMessageAsync(Message message, string sendersName, st { // Weather alerts carry their own descriptive subject (e.g. "[Severe] Weather Alert: Tornado Warning") // and headline, so skip the generic "Msg:" prefix and "Msg from System" subtitle. - spm.Title = message.Subject.Truncate(200); + spm.Title = pushMessage.Subject.Truncate(200); if (!String.IsNullOrWhiteSpace(message.PushSubTitle)) spm.SubTitle = message.PushSubTitle.Truncate(200); @@ -115,7 +128,9 @@ public async Task SendMessageAsync(Message message, string sendersName, st else spm.SubTitle = string.Format("Msg from {0}", sendersName); - spm.Title = "Msg:" + message.Subject.Truncate(200); + // The push title is the notification a phone shows on the lock screen - the one + // place a protected subject would be readable without signing in. + spm.Title = "Msg:" + pushMessage.Subject.Truncate(200); } @@ -137,12 +152,17 @@ public async Task SendMessageAsync(Message message, string sendersName, st // Outbound chat platforms (Discord/Slack/etc.) as a sibling channel; failures are isolated. try { + // ChatPlatform is always sanitized for a protected department (plan 9.1): the + // content would land in a third-party workspace outside the department's control. + var chatMessage = await _protectedProjectionService.BuildNotificationSafeMessageAsync( + departmentId, message, ProtectedDataEgressChannel.ChatPlatform, profile?.Language); + await _chatbotOutboundService.SendToUserAsync(message.ReceivingUserId, departmentId, new ChatbotOutboundMessage { Type = ChatbotOutboundType.Message, - Title = message.Subject, - Body = message.Body, + Title = chatMessage.Subject, + Body = chatMessage.Body, ReferenceId = message.MessageId.ToString() }); } @@ -170,11 +190,11 @@ public async Task SendCallAsync(Call call, CallDispatch dispatch, string d // 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); + var chatCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.ChatPlatform, profile?.Language); + var pushCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Push, profile?.Language); + var smsCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Sms, profile?.Language); + var emailCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Email, profile?.Language); + var voiceCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Voice, profile?.Language); // The pre-resolved address parameter is protected location data; it only survives for a // channel whose safe view is the original call. @@ -414,9 +434,9 @@ public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, st // 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 pushCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Push, profile?.Language); + var smsCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Sms, profile?.Language); + var emailCall = await _protectedProjectionService.BuildNotificationSafeCallAsync(departmentId, call, ProtectedDataEgressChannel.Email, profile?.Language); var pushAddress = ReferenceEquals(pushCall, call) ? address : null; // Send a Push Notification @@ -819,6 +839,10 @@ public async Task SendTroubleAlertAsync(TroubleAlertEvent troubleAlertEven // 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. + // + // No recipient language here, unlike the per-member dispatch paths: a trouble alert fans + // out to a whole roster in one call, so the safe view is built once for readers who may + // not share a language. It falls back to English rather than picking one member's. 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; diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs index 22591dc4b..a6bb56788 100644 --- a/Core/Resgrid.Services/DeleteService.cs +++ b/Core/Resgrid.Services/DeleteService.cs @@ -258,6 +258,10 @@ private async Task DeactivateUserAccountCoreAsync(string user userProfile.SecurityPin = null; userProfile.SecurityPinEnabled = false; + // M0141 (contract) cleared these links; a member's addresses live on their + // department-scoped DepartmentMemberSensitiveData rows, which this service deletes + // with the rest of the department data. Kept as a defensive sweep for any row a + // pre-contract deployment left behind. if (userProfile.HomeAddressId.HasValue) await _addressService.DeleteAddress(userProfile.HomeAddressId.Value, cancellationToken); diff --git a/Core/Resgrid.Services/DepartmentDataProtectionService.cs b/Core/Resgrid.Services/DepartmentDataProtectionService.cs index be7c18232..93a591885 100644 --- a/Core/Resgrid.Services/DepartmentDataProtectionService.cs +++ b/Core/Resgrid.Services/DepartmentDataProtectionService.cs @@ -31,11 +31,13 @@ public class DepartmentDataProtectionService : IDepartmentDataProtectionService private readonly ISubscriptionsService _subscriptionsService; private readonly ICacheProvider _cacheProvider; private readonly IProtectedFieldCatalog _fieldCatalog; + private readonly IDepartmentDataProtectionMigrationRepository _migrationRepository; public DepartmentDataProtectionService(IDepartmentDataProtectionPolicyRepository policyRepository, IDepartmentProtectedDataEgressPolicyRepository egressPolicyRepository, IDepartmentsService departmentsService, IFeatureToggleService featureToggleService, ISubscriptionsService subscriptionsService, - ICacheProvider cacheProvider, IProtectedFieldCatalog fieldCatalog) + ICacheProvider cacheProvider, IProtectedFieldCatalog fieldCatalog, + IDepartmentDataProtectionMigrationRepository migrationRepository) { _policyRepository = policyRepository; _egressPolicyRepository = egressPolicyRepository; @@ -44,6 +46,44 @@ public DepartmentDataProtectionService(IDepartmentDataProtectionPolicyRepository _subscriptionsService = subscriptionsService; _cacheProvider = cacheProvider; _fieldCatalog = fieldCatalog; + _migrationRepository = migrationRepository; + } + + public async Task GetMigrationProgressAsync(int departmentId, + CancellationToken cancellationToken = default) + { + var progress = new AdpMigrationProgress(); + + // bypassCache: the panel is watched while a run is moving, and a stale kind would point + // the query at the wrong set of cursor rows. + var policy = await GetPolicyByDepartmentIdAsync(departmentId, bypassCache: true); + if (policy?.ActiveMigrationKind == null) + return progress; + + progress.Kind = (DepartmentDataProtectionMigrationKind)policy.ActiveMigrationKind.Value; + + var rows = await _migrationRepository.GetActiveByDepartmentIdAsync(departmentId, progress.Kind); + if (rows == null || rows.Count == 0) + return progress; + + progress.IsRunning = true; + progress.TablesStarted = rows.Count; + progress.RowsTotal = rows.Sum(r => r.RowsTotal); + progress.RowsCompleted = rows.Sum(r => r.RowsProcessed + r.RowsAlreadyProtected); + progress.RowsAnomalous = rows.Sum(r => r.RowsAnomalous); + + // Same formula as DepartmentDataMigrationEngine.ComputePercentCompleteAsync, so the two + // never report different numbers for the same run. + if (progress.RowsTotal > 0) + progress.PercentComplete = (int)Math.Min(100, progress.RowsCompleted * 100 / progress.RowsTotal); + + progress.CurrentTable = rows + .OrderBy(r => r.RowsTotal <= 0 ? 1d : (double)(r.RowsProcessed + r.RowsAlreadyProtected) / r.RowsTotal) + .ThenBy(r => r.TargetTable) + .Select(r => r.TargetTable) + .FirstOrDefault(); + + return progress; } public async Task GetPinnedCatalogVersionAsync(int departmentId) @@ -307,6 +347,139 @@ public async Task CancelQueuedEnrollme return rows > 0 ? DepartmentDataProtectionEnrollmentResult.Queued : DepartmentDataProtectionEnrollmentResult.InvalidState; } + public async Task ApplyAddonBillingEventAsync( + AdpAddonBillingEvent billingEvent, CancellationToken cancellationToken = default) + { + if (billingEvent == null || billingEvent.DepartmentId <= 0) + return DepartmentDataProtectionEnrollmentResult.InvalidState; + + try + { + var policy = await GetPolicyByDepartmentIdAsync(billingEvent.DepartmentId, bypassCache: true); + + // A department that has never touched ADP has no policy row. Activation and renewal + // are simply recorded as "may enroll" by the addon existing at all, so there is + // nothing to write; a cancellation for a department with no policy is a no-op. + if (policy == null) + return billingEvent.Kind == AdpAddonBillingEventKind.Cancelled + ? DepartmentDataProtectionEnrollmentResult.InvalidState + : DepartmentDataProtectionEnrollmentResult.Queued; + + // Idempotency. Providers retry and duplicate webhooks; an event already applied is + // acknowledged rather than re-run, so a replayed Cancelled cannot re-schedule an + // offboarding a member has since revoked. + if (!string.IsNullOrWhiteSpace(billingEvent.ProviderEventId) && + string.Equals(policy.LastBillingEventId, billingEvent.ProviderEventId, StringComparison.OrdinalIgnoreCase)) + return DepartmentDataProtectionEnrollmentResult.Queued; + + var result = DepartmentDataProtectionEnrollmentResult.Queued; + + switch (billingEvent.Kind) + { + case AdpAddonBillingEventKind.Activated: + case AdpAddonBillingEventKind.Renewed: + // No crypto change (plan 17.3). Renewal after a cancellation is the provider + // telling us the subscription is alive again, so a scheduled offboarding that + // has not started yet is withdrawn — this is what settles an out-of-order + // Cancelled-then-Renewed pair on the provider's current truth. + if ((DepartmentDataProtectionState)policy.State == DepartmentDataProtectionState.OffboardingScheduled) + result = await RevokeScheduledOffboardingForBillingAsync(billingEvent.DepartmentId, cancellationToken); + break; + + case AdpAddonBillingEventKind.PaymentFailed: + // Dunning changes nothing about protection (plan 17.3). Recorded for the + // audit line and nothing else; exhausted dunning arrives later as Cancelled. + Logging.LogInfo($"ADP addon payment failed for department {billingEvent.DepartmentId} " + + $"(provider {billingEvent.ProviderName}, dunning {billingEvent.DunningState}); protection continues."); + break; + + case AdpAddonBillingEventKind.Cancelled: + var source = billingEvent.IsChargeback + ? DepartmentDataProtectionOffboardingSource.Chargeback + : billingEvent.IsDunningExhausted + ? DepartmentDataProtectionOffboardingSource.DunningExhausted + : DepartmentDataProtectionOffboardingSource.UserCancelled; + + // A chargeback or refund ends the paid cycle immediately, but offboarding + // still runs through the normal worker path — never an instant crypto flip. + var effectiveOn = billingEvent.EffectiveEndUtc ?? DateTime.UtcNow; + + // Already scheduled: the provider is repeating itself. Re-scheduling would + // move a date a member may have been told, so it is left alone. + if ((DepartmentDataProtectionState)policy.State == DepartmentDataProtectionState.OffboardingScheduled) + break; + + result = await ScheduleOffboardingAsync(billingEvent.DepartmentId, source, effectiveOn, cancellationToken); + + // Mid-enrollment cancellations return InvalidState by design: the enrollment + // finishes to Enabled first (plan 21.3) and the reconciliation re-runs then. + // That is not a failure, so it is recorded rather than surfaced as one. + if (result == DepartmentDataProtectionEnrollmentResult.InvalidState) + Logging.LogInfo($"ADP cancellation for department {billingEvent.DepartmentId} deferred: " + + $"state {(DepartmentDataProtectionState)policy.State} completes first (plan 21.3)."); + break; + } + + await RecordBillingEventAsync(billingEvent, cancellationToken); + return result; + } + catch (Exception ex) + { + Logging.LogException(ex, $"ADP ApplyAddonBillingEventAsync failed for department {billingEvent.DepartmentId}"); + return DepartmentDataProtectionEnrollmentResult.Failed; + } + } + + /// + /// Withdraws an offboarding that billing has superseded. Deliberately NOT RevokeOffboardingAsync: + /// that one is the member-facing command and enforces managing-member authorization, which a + /// billing event does not have and should not need. + /// + private async Task RevokeScheduledOffboardingForBillingAsync( + int departmentId, CancellationToken cancellationToken) + { + var rows = await _policyRepository.TryTransitionStateAsync(departmentId, + DepartmentDataProtectionState.OffboardingScheduled, DepartmentDataProtectionState.Enabled, + null, "system:billing", cancellationToken); + + if (rows == 0) + return DepartmentDataProtectionEnrollmentResult.InvalidState; + + var policy = await _policyRepository.GetByDepartmentIdAsync(departmentId); + if (policy != null) + { + policy.OffboardingEffectiveOn = null; + policy.OffboardingSource = null; + policy.UpdatedOn = DateTime.UtcNow; + policy.UpdatedByUserId = "system:billing"; + await _policyRepository.SaveOrUpdateAsync(policy, cancellationToken); + } + + await InvalidateProtectionCacheAsync(departmentId); + return DepartmentDataProtectionEnrollmentResult.Queued; + } + + /// + /// Stamps the subscription reference and the applied event id. The id is what makes a repeat + /// of the same webhook a no-op above. + /// + private async Task RecordBillingEventAsync(AdpAddonBillingEvent billingEvent, CancellationToken cancellationToken) + { + var policy = await _policyRepository.GetByDepartmentIdAsync(billingEvent.DepartmentId); + if (policy == null) + return; + + if (!string.IsNullOrWhiteSpace(billingEvent.ExternalSubscriptionRef)) + policy.AddonBillingReference = billingEvent.ExternalSubscriptionRef; + + policy.LastBillingEventId = billingEvent.ProviderEventId; + policy.UpdatedOn = DateTime.UtcNow; + policy.UpdatedByUserId = "system:billing"; + + await _policyRepository.SaveOrUpdateAsync(policy, cancellationToken); + await InvalidateProtectionCacheAsync(billingEvent.DepartmentId); + } + public async Task ScheduleOffboardingAsync(int departmentId, DepartmentDataProtectionOffboardingSource source, DateTime effectiveOnUtc, CancellationToken cancellationToken = default) diff --git a/Core/Resgrid.Services/DepartmentMemberEmergencyContactService.cs b/Core/Resgrid.Services/DepartmentMemberEmergencyContactService.cs index 4a2d96929..cffe32641 100644 --- a/Core/Resgrid.Services/DepartmentMemberEmergencyContactService.cs +++ b/Core/Resgrid.Services/DepartmentMemberEmergencyContactService.cs @@ -45,17 +45,32 @@ public async Task SaveAsync(DepartmentMemberEm else contact.UpdatedOn = DateTime.UtcNow; + // ADP write safety net (plan 4.2/19.2). The AAD row key is the identity pk, so a NEW row + // must be inserted before it can be enveloped, and that insert is a transient plaintext + // write. An UPDATE already has its id, so it is enveloped BEFORE the save and no + // plaintext ever reaches the table - which is the common path here, since a member + // edits this data far more often than they first fill it in. Fails closed either way. + var isExistingRow = contact.DepartmentMemberEmergencyContactId > 0; + + if (isExistingRow) + { + var preSaveWrite = await _protectedWriteService.Value.PrepareMemberEmergencyContactWriteAsync( + contact.DepartmentId, contact, null, null, workloadCaller: true, cancellationToken); + if (!preSaveWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({preSaveWrite.Reason}); emergency contact {contact.DepartmentMemberEmergencyContactId} was NOT saved."); + } + var saved = await _repository.SaveOrUpdateAsync(contact, cancellationToken); - // ADP write safety net (plan 4.2/19.2). Runs AFTER the save so the identity pk exists — - // it is the AAD row key — then re-persists the enveloped row. Fails closed by throwing - // rather than leaving next-of-kin details in plaintext. - var protectedWrite = await _protectedWriteService.Value.PrepareMemberEmergencyContactWriteAsync( - saved.DepartmentId, saved, null, null, workloadCaller: true, cancellationToken); - if (!protectedWrite.Success) - throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); emergency contact {saved.DepartmentMemberEmergencyContactId} has transient plaintext pending re-encryption."); - if (protectedWrite.Changed) - saved = await _repository.SaveOrUpdateAsync(saved, cancellationToken); + if (!isExistingRow) + { + var protectedWrite = await _protectedWriteService.Value.PrepareMemberEmergencyContactWriteAsync( + saved.DepartmentId, saved, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); emergency contact {saved.DepartmentMemberEmergencyContactId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _repository.SaveOrUpdateAsync(saved, cancellationToken); + } // Exactly one primary per member. "Who do we call first" has to have a single answer, and // nothing in the schema enforces it. Demotion runs after the save so the new row has its diff --git a/Core/Resgrid.Services/DepartmentMemberSensitiveDataService.cs b/Core/Resgrid.Services/DepartmentMemberSensitiveDataService.cs index 55044c4aa..bb8a5be91 100644 --- a/Core/Resgrid.Services/DepartmentMemberSensitiveDataService.cs +++ b/Core/Resgrid.Services/DepartmentMemberSensitiveDataService.cs @@ -94,17 +94,32 @@ public async Task SaveAsync(DepartmentMemberSensi if (string.IsNullOrWhiteSpace(data.ProtectionId)) data.ProtectionId = Guid.NewGuid().ToString("N"); + // ADP write safety net (plan 4.2/19.2). The AAD row key is the identity pk, so a NEW row + // must be inserted before it can be enveloped, and that insert is a transient plaintext + // write. An UPDATE already has its id, so it is enveloped BEFORE the save and no + // plaintext ever reaches the table - which is the common path here, since a member + // edits this data far more often than they first fill it in. Fails closed either way. + var isExistingRow = data.DepartmentMemberSensitiveDataId > 0; + + if (isExistingRow) + { + var preSaveWrite = await _protectedWriteService.Value.PrepareMemberSensitiveDataWriteAsync( + data.DepartmentId, data, null, null, workloadCaller: true, cancellationToken); + if (!preSaveWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({preSaveWrite.Reason}); member sensitive data {data.DepartmentMemberSensitiveDataId} was NOT saved."); + } + var saved = await _repository.SaveOrUpdateAsync(data, cancellationToken); - // ADP write safety net (plan 4.2/19.2). Runs AFTER the save so the identity pk exists — - // it is the AAD row key — then re-persists the enveloped row. Fails closed by throwing - // rather than leaving a member's identification number in plaintext. - var protectedWrite = await _protectedWriteService.Value.PrepareMemberSensitiveDataWriteAsync( - saved.DepartmentId, saved, null, null, workloadCaller: true, cancellationToken); - if (!protectedWrite.Success) - throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); member sensitive data {saved.DepartmentMemberSensitiveDataId} has transient plaintext pending re-encryption."); - if (protectedWrite.Changed) - saved = await _repository.SaveOrUpdateAsync(saved, cancellationToken); + if (!isExistingRow) + { + var protectedWrite = await _protectedWriteService.Value.PrepareMemberSensitiveDataWriteAsync( + saved.DepartmentId, saved, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); member sensitive data {saved.DepartmentMemberSensitiveDataId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _repository.SaveOrUpdateAsync(saved, cancellationToken); + } return saved; } diff --git a/Core/Resgrid.Services/DistributionListsService.cs b/Core/Resgrid.Services/DistributionListsService.cs index 1ad2ecbd3..e68a87fed 100644 --- a/Core/Resgrid.Services/DistributionListsService.cs +++ b/Core/Resgrid.Services/DistributionListsService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -11,10 +12,14 @@ namespace Resgrid.Services public class DistributionListsService : IDistributionListsService { private readonly IDistributionListRepository _distributionListRepository; + private readonly Lazy _protectedWriteService; private readonly IDistributionListMemberRepository _distributionListMemberRepository; - public DistributionListsService(IDistributionListRepository distributionListRepository, IDistributionListMemberRepository distributionListMemberRepository) + public DistributionListsService(IDistributionListRepository distributionListRepository, + IDistributionListMemberRepository distributionListMemberRepository, + Lazy protectedWriteService) { + _protectedWriteService = protectedWriteService; _distributionListRepository = distributionListRepository; _distributionListMemberRepository = distributionListMemberRepository; } @@ -57,8 +62,22 @@ public async Task GetDistributionListByAddressAsync(string ema public async Task SaveDistributionListAsync(DistributionList distributionList, CancellationToken cancellationToken = default(CancellationToken)) { + DistributionList existing = null; + if (distributionList != null && distributionList.DistributionListId > 0) + existing = await _distributionListRepository.GetByIdAsync(distributionList.DistributionListId); + var savedList = await _distributionListRepository.SaveOrUpdateAsync(distributionList, cancellationToken); + // ADP write safety net (plan 4.2/19.2, catalog v9). Runs AFTER the save because the AAD + // row key is the identity pk, then re-persists the enveloped row. Fails closed by + // throwing rather than leaving the value in plaintext. + var protectedWrite = await _protectedWriteService.Value.PrepareDistributionListWriteAsync( + savedList.DepartmentId, savedList, existing, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); distribution list {savedList.DistributionListId} has transient plaintext credentials pending re-encryption."); + if (protectedWrite.Changed) + savedList = await _distributionListRepository.SaveOrUpdateAsync(savedList, cancellationToken); + if (distributionList.Members != null && distributionList.Members.Any()) { foreach (var distributionListMember in distributionList.Members) diff --git a/Core/Resgrid.Services/DocumentsService.cs b/Core/Resgrid.Services/DocumentsService.cs index 1c9472be0..4492c95dd 100644 --- a/Core/Resgrid.Services/DocumentsService.cs +++ b/Core/Resgrid.Services/DocumentsService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -14,11 +15,14 @@ namespace Resgrid.Services public class DocumentsService : IDocumentsService { private readonly IDocumentRepository _documentRepository; + private readonly Lazy _protectedWriteService; private readonly IDocumentCategoriesRepository _documentCategoriesRepository; private readonly IEventAggregator _eventAggregator; - public DocumentsService(IDocumentRepository documentRepository, IDocumentCategoriesRepository documentCategoriesRepository, IEventAggregator eventAggregator) + public DocumentsService(IDocumentRepository documentRepository, IDocumentCategoriesRepository documentCategoriesRepository, + IEventAggregator eventAggregator, Lazy protectedWriteService) { + _protectedWriteService = protectedWriteService; _documentRepository = documentRepository; _documentCategoriesRepository = documentCategoriesRepository; _eventAggregator = eventAggregator; @@ -65,7 +69,23 @@ public async Task> GetFilteredDocumentsByDepartmentIdAsync(int de public async Task SaveDocumentAsync(Document document, CancellationToken cancellationToken = default(CancellationToken)) { - return await _documentRepository.SaveOrUpdateAsync(document, cancellationToken); + Document existing = null; + if (document != null && document.DocumentId > 0) + existing = await _documentRepository.GetByIdAsync(document.DocumentId); + + var saved = await _documentRepository.SaveOrUpdateAsync(document, cancellationToken); + + // ADP write safety net (plan 4.2/19.2, catalog v9). Runs AFTER the save because the AAD + // row key is the identity pk, then re-persists the enveloped row. Fails closed by + // throwing rather than leaving the value in plaintext. + var protectedWrite = await _protectedWriteService.Value.PrepareDocumentWriteAsync(saved.DepartmentId, + saved, existing, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); document {saved.DocumentId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _documentRepository.SaveOrUpdateAsync(saved, cancellationToken); + + return saved; } public async Task> GetDistinctCategoriesByDepartmentIdAsync(int departmentId) diff --git a/Core/Resgrid.Services/MessageService.cs b/Core/Resgrid.Services/MessageService.cs index f6def6c83..f6bfba83d 100644 --- a/Core/Resgrid.Services/MessageService.cs +++ b/Core/Resgrid.Services/MessageService.cs @@ -19,11 +19,13 @@ public class MessageService : IMessageService private readonly IQueueService _queueService; private readonly IUserProfileService _userProfileService; private readonly IMessageRecipientRepository _messageRecipientRepository; + private readonly Lazy _protectedWriteService; public MessageService(IMessageRepository messageRepository, IPushService pushService, ICommunicationService communicationService, IQueueService queueService, IUserProfileService userProfileService, - IMessageRecipientRepository messageRecipientRepository) + IMessageRecipientRepository messageRecipientRepository, + Lazy protectedWriteService) { _messageRepository = messageRepository; _pushService = pushService; @@ -31,6 +33,7 @@ public MessageService(IMessageRepository messageRepository, IPushService pushSer _queueService = queueService; _userProfileService = userProfileService; _messageRecipientRepository = messageRecipientRepository; + _protectedWriteService = protectedWriteService; } public async Task GetMessageByIdAsync(int messageId) @@ -44,10 +47,50 @@ public async Task GetMessageByIdAsync(int messageId) message.Body = message.Body?.Truncate(Message.MaximumBodyLength); message.SentOn = message.SentOn.ToUniversalTime(); + // Recipients are cascade-saved with their parent, so they take the parent's owner here + // rather than each caller remembering to set it (M0137). Only rows that do not already + // carry one: a recipient row never changes department after it is written. + if (message.DepartmentId.HasValue && message.MessageRecipients != null) + { + foreach (var recipient in message.MessageRecipients) + { + if (!recipient.DepartmentId.HasValue) + recipient.DepartmentId = message.DepartmentId; + } + } + if (message.ReadOn.HasValue) message.ReadOn = message.ReadOn.Value.ToUniversalTime(); - return await _messageRepository.SaveOrUpdateAsync(message, cancellationToken); + var saved = await _messageRepository.SaveOrUpdateAsync(message, cancellationToken); + + // ADP write safety net (plan 4.2/19.2, catalog v7). Runs AFTER the save because the AAD + // row key is the identity pk, and because the recipient rows are cascade-saved by that + // same call - their ids do not exist until it returns. Fails closed by throwing rather + // than leaving a member's message body in plaintext. + var protectedWrite = await _protectedWriteService.Value.PrepareMessageWriteAsync( + saved.DepartmentId ?? 0, saved, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); message {saved.MessageId} has transient plaintext pending re-encryption."); + + var recipientChanged = false; + if (saved.MessageRecipients != null) + { + foreach (var recipient in saved.MessageRecipients.Where(r => r != null)) + { + var recipientWrite = await _protectedWriteService.Value.PrepareMessageRecipientWriteAsync( + saved.DepartmentId ?? 0, recipient, null, null, workloadCaller: true, cancellationToken); + if (!recipientWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({recipientWrite.Reason}); message recipient {recipient.MessageRecipientId} has transient plaintext pending re-encryption."); + + recipientChanged |= recipientWrite.Changed; + } + } + + if (protectedWrite.Changed || recipientChanged) + saved = await _messageRepository.SaveOrUpdateAsync(saved, cancellationToken); + + return saved; } public async Task> GetInboxMessagesByUserIdAsync(string userId) @@ -109,6 +152,18 @@ private static bool IsActiveInboxMessage(Message message) return await _messageRepository.UpdateRecievedMessagesAsReadAsync(userId, messageIds); } + private async Task EnsureRecipientOwnerAsync(MessageRecipient recipient) + { + // A recipient row saved on its own (marking it read, recording an RSVP response) has no + // department in hand. One keyed read of the parent fills it; rows written after M0137 + // already carry one, so this only fires on the historic tail. + if (recipient == null || recipient.DepartmentId.HasValue || recipient.MessageId <= 0) + return; + + var parent = await _messageRepository.GetMessagesByMessageIdAsync(recipient.MessageId); + recipient.DepartmentId = parent?.DepartmentId; + } + public async Task MarkMessageRecipientAsDeletedAsync(int messageId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var message = await GetMessageRecipientByMessageAndUserAsync(messageId, userId); @@ -119,6 +174,19 @@ private static bool IsActiveInboxMessage(Message message) public async Task SendMessageAsync(Message message, string sendersName, int departmentId, bool broadcastSingle = true, CancellationToken cancellationToken = default(CancellationToken)) { + // Send is the only point in the pipeline that is told which department this belongs to, + // and every producer saves the message and then sends it. Stamping the owner here (and + // re-persisting when the row was saved without one) is what keeps M0137's column + // populated going forward; without it every new row would be as unattributable as the + // historic ones the backfill could not resolve. + if (departmentId > 0 && !message.DepartmentId.HasValue) + { + message.DepartmentId = departmentId; + + if (message.MessageId > 0) + await SaveMessageAsync(message, cancellationToken); + } + if (broadcastSingle) { foreach (var recip in message.GetRecipients()) @@ -129,6 +197,7 @@ private static bool IsActiveInboxMessage(Message message) m.SendingUserId = message.SendingUserId; m.ReceivingUserId = recip; m.SentOn = message.SentOn; + m.DepartmentId = departmentId > 0 ? departmentId : message.DepartmentId; var savedMessage = await SaveMessageAsync(m, cancellationToken); @@ -223,7 +292,21 @@ public async Task GetMessageRecipientByMessageAndUserAsync(int public async Task SaveMessageRecipientAsync(MessageRecipient messageRecipient, CancellationToken cancellationToken = default(CancellationToken)) { - return await _messageRecipientRepository.SaveOrUpdateAsync(messageRecipient, cancellationToken); + await EnsureRecipientOwnerAsync(messageRecipient); + + var saved = await _messageRecipientRepository.SaveOrUpdateAsync(messageRecipient, cancellationToken); + + // A reply arrives as plaintext from an inbound SMS, so this row needs the net as much as + // the parent does (catalog v7). + var protectedWrite = await _protectedWriteService.Value.PrepareMessageRecipientWriteAsync( + saved.DepartmentId ?? 0, saved, null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); message recipient {saved.MessageRecipientId} has transient plaintext pending re-encryption."); + + if (protectedWrite.Changed) + saved = await _messageRecipientRepository.SaveOrUpdateAsync(saved, cancellationToken); + + return saved; } } } diff --git a/Core/Resgrid.Services/ModerationService.cs b/Core/Resgrid.Services/ModerationService.cs index 872d9856e..32c662791 100644 --- a/Core/Resgrid.Services/ModerationService.cs +++ b/Core/Resgrid.Services/ModerationService.cs @@ -45,6 +45,7 @@ public class ModerationService : IModerationService private readonly IUserProfileService _userProfileService; private readonly IUnitOfWork _unitOfWork; private readonly IOutboundQueueProvider _outboundQueueProvider; + private readonly Lazy _protectedWriteService; public ModerationService(IModerationRequestRepository moderationRequestRepository, IModerationReportRepository moderationReportRepository, IModerationActionRepository moderationActionRepository, @@ -55,7 +56,7 @@ public ModerationService(IModerationRequestRepository moderationRequestRepositor ICallsService callsService, IDepartmentGroupsService departmentGroupsService, IAuthorizationService authorizationService, IAuditService auditService, IUserProfileService userProfileService, IUnitOfWork unitOfWork, - IOutboundQueueProvider outboundQueueProvider) + IOutboundQueueProvider outboundQueueProvider, Lazy protectedWriteService) { _moderationRequestRepository = moderationRequestRepository; _moderationReportRepository = moderationReportRepository; @@ -75,6 +76,22 @@ public ModerationService(IModerationRequestRepository moderationRequestRepositor _userProfileService = userProfileService; _unitOfWork = unitOfWork; _outboundQueueProvider = outboundQueueProvider; + _protectedWriteService = protectedWriteService; + } + + /// + /// ADP write safety net (plan 5.3, catalog v8). Moderation rows carry a VERBATIM copy of the + /// worst content the department holds, so for an enrolled department they are enveloped + /// BEFORE the insert - the primary key is assigned here rather than by the database, so + /// unlike the identity-keyed families there is no moment where plaintext sits in the table. + /// Fails closed by throwing: a moderation record that cannot be protected must not be + /// written at all. + /// + private async Task ProtectModerationWriteAsync(Func> prepare, string description) + { + var result = await prepare(); + if (!result.Success) + throw new InvalidOperationException($"Protected write blocked ({result.Reason}); {description} was NOT saved."); } public async Task FlagAsync(int departmentId, string reportedByUserId, @@ -117,6 +134,11 @@ public async Task FlagAsync(int departmentId, string reportedB ModifiedOn = now }; + await ProtectModerationWriteAsync( + () => _protectedWriteService.Value.PrepareModerationRequestWriteAsync(departmentId, request, + null, null, workloadCaller: true, cancellationToken), + $"moderation request for {itemType} {itemId}"); + try { request = await _moderationRequestRepository.InsertAsync(request, cancellationToken); @@ -190,6 +212,11 @@ await RecordDepartmentAuditAsync(request, AuditLogTypes.ModerationRequestReopene ReportedOn = DateTime.UtcNow }; + await ProtectModerationWriteAsync( + () => _protectedWriteService.Value.PrepareModerationReportWriteAsync(report.DepartmentId, report, + null, null, workloadCaller: true, cancellationToken), + $"moderation report {report.ModerationReportId}"); + try { report = await _moderationReportRepository.InsertAsync(report, cancellationToken); @@ -714,7 +741,18 @@ private async Task RecordActionAsync(ModerationRequest request, ModerationAction string byUserId, string note, int? previousStatus, int? newStatus, ChatModerationContext context, object details, CancellationToken cancellationToken, bool includeEvidence = false) { - await _moderationActionRepository.InsertAsync(new ModerationAction + // The evidence snapshot is a COPY of the request's own columns, and an envelope is bound + // to the field id and row key it was written for - copying ciphertext into a different + // row would produce bytes nothing can ever decrypt. So evidence is copied only while the + // request is still plaintext; for a protected department the action points at the + // request (ModerationRequestId), which holds the protected original and is not deleted + // when the moderated item is. + var evidenceIsCopyable = includeEvidence + && !ProtectedDataEnvelope.HasEnvelopePrefix(request.OriginalText) + && !ProtectedDataEnvelope.HasEnvelopePrefix(request.OriginalMetadataJson) + && !ProtectedReadService.IsBinaryEnveloped(request.OriginalContent); + + var action = new ModerationAction { ModerationActionId = Guid.NewGuid().ToString(), ModerationRequestId = request.ModerationRequestId, @@ -731,10 +769,17 @@ await _moderationActionRepository.InsertAsync(new ModerationAction TraceId = context?.TraceId, ServerName = Environment.MachineName, DetailsJson = details == null ? null : JsonConvert.SerializeObject(details), - EvidenceText = includeEvidence ? request.OriginalText : null, - EvidenceContent = includeEvidence ? request.OriginalContent : null, - EvidenceMetadataJson = includeEvidence ? request.OriginalMetadataJson : null - }, cancellationToken); + EvidenceText = evidenceIsCopyable ? request.OriginalText : null, + EvidenceContent = evidenceIsCopyable ? request.OriginalContent : null, + EvidenceMetadataJson = evidenceIsCopyable ? request.OriginalMetadataJson : null + }; + + await ProtectModerationWriteAsync( + () => _protectedWriteService.Value.PrepareModerationActionWriteAsync(action.DepartmentId, action, + null, null, workloadCaller: true, cancellationToken), + $"moderation action {action.ModerationActionId}"); + + await _moderationActionRepository.InsertAsync(action, cancellationToken); } private async Task RecordDepartmentAuditAsync(ModerationRequest request, AuditLogTypes logType, diff --git a/Core/Resgrid.Services/ProtectedFieldCatalog.cs b/Core/Resgrid.Services/ProtectedFieldCatalog.cs index 3ebf67da5..26869afed 100644 --- a/Core/Resgrid.Services/ProtectedFieldCatalog.cs +++ b/Core/Resgrid.Services/ProtectedFieldCatalog.cs @@ -20,6 +20,10 @@ public class ProtectedFieldCatalog : IProtectedFieldCatalog private const string PersonnelFamily = "Personnel"; private const string ContactsFamily = "Contacts"; private const string OperationalFamily = "Operational"; + private const string MessagingFamily = "Messaging"; + private const string ModerationFamily = "Moderation"; + private const string DocumentsFamily = "Documents"; + private const string CredentialsFamily = "Credentials"; /// Catalog version the section 5.2 operational entries were added in. private const int OperationalCatalogVersion = 2; @@ -36,6 +40,18 @@ public class ProtectedFieldCatalog : IProtectedFieldCatalog /// Catalog version the personnel certification family was added in. private const int CertificationCatalogVersion = 6; + /// Catalog version the member-messaging family was added in. + private const int MessagingCatalogVersion = 7; + + /// Catalog version the moderation family was added in. + private const int ModerationCatalogVersion = 8; + + /// + /// Catalog version the plan's remaining candidates were added in: unit logs, user state + /// notes, calendar items, documents and the stored mailbox credentials. + /// + private const int RemainingCandidatesCatalogVersion = 9; + private static readonly IReadOnlyList Entries = BuildV1(); private static readonly Dictionary ById = Entries.ToDictionary(e => e.FieldId, StringComparer.OrdinalIgnoreCase); @@ -285,6 +301,141 @@ void LogField(string column, ProtectedFieldClassification classification) => list.Add(new ProtectedFieldDefinition("contactnotes.note", ContactsFamily, "ContactNotes", "Note", ProtectedFieldStorageKind.Text, ProtectedFieldClassification.Sensitive, PermissionTypes.ViewProtectedContactData)); + // ---- Member messaging (section 5.2), catalog v7 ----------------------------------- + // Member-to-member messages are free text about incidents, patients and people, and the + // reply carries whatever the member typed back; the position a reply was filed from is + // protected location data and rides the companion columns. Both tables became bindable + // only with M0137, which gave them a DepartmentId of their own — an envelope AAD binds + // the department, and until then these rows could not be attributed to one. + // + // Subject is cataloged with the body deliberately. A subject line like "Overdose at 14 + // Elm - do not tell the family" discloses as much as the message, and leaving it in the + // clear would leave every inbox listing readable. + // + // MessageRecipients.Note needed M0138 before it could be listed here. It used to be + // dual-purpose - besides a member's typed note it carried the TextResponsePromptMetadata + // token naming the calendar item or poll a prompt belongs to, parsed by the chatbot + // inbound resolver, the RSVP prompt service and both message controllers. Those paths + // hold NO grant and the broker's workload lane is encrypt-only, so encrypting the column + // would have silently broken calendar RSVP and poll replies for the departments that + // turned protection on. M0138 moved the token to MessageRecipients.PromptMetadata (which + // stays plaintext: it is a row pointer and says nothing about a person), leaving Note as + // ordinary member free text and part of this family. + void MessageField(string table, string column, ProtectedFieldClassification classification, + ProtectedFieldStorageKind kind = ProtectedFieldStorageKind.Text) => + list.Add(new ProtectedFieldDefinition($"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", + MessagingFamily, table, column, kind, classification, + PermissionTypes.ViewProtectedOperationalData, PermissionTypes.ViewProtectedOperationalData, + MessagingCatalogVersion)); + + MessageField("Messages", "Subject", ProtectedFieldClassification.Sensitive); + MessageField("Messages", "Body", ProtectedFieldClassification.Sensitive); + MessageField("MessageRecipients", "Response", ProtectedFieldClassification.Sensitive); + MessageField("MessageRecipients", "Note", ProtectedFieldClassification.Sensitive); + MessageField("MessageRecipients", "Latitude", ProtectedFieldClassification.Sensitive, + ProtectedFieldStorageKind.CompanionColumn); + MessageField("MessageRecipients", "Longitude", ProtectedFieldClassification.Sensitive, + ProtectedFieldStorageKind.CompanionColumn); + + // ---- Moderation (section 5.3), catalog v8 ------------------------------------------ + // A moderation record is a VERBATIM COPY of the worst content the department holds: the + // message or note that was reported, the file that came with it, and the moderator's + // account of why. Leaving it in the clear would mean a protected department encrypts + // the original and keeps a plaintext duplicate one table over, reachable by anyone who + // can read the queue. + // + // Moderators need their normal permission AND a current grant (plan 5.3). The queue + // itself stays usable without one: status, reason CODE and counts are structural and + // stay plaintext, so a moderator can triage; only the excerpts need the step-up. + // + // NOT cataloged, deliberately: ModerationActions.ActorRole / IpAddress / UserAgent / + // TraceId / ServerName. Those are the security audit trail of who acted and from where + // (section 5.4), they are not the reported content, and encrypting them would blind the + // very trail that exists to investigate abuse of the moderation tools themselves. + void Moderation(string table, string column, ProtectedFieldClassification classification, + ProtectedFieldStorageKind kind = ProtectedFieldStorageKind.Text) => + list.Add(new ProtectedFieldDefinition($"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", + ModerationFamily, table, column, kind, classification, + PermissionTypes.ViewProtectedOperationalData, PermissionTypes.ViewProtectedOperationalData, + ModerationCatalogVersion)); + + Moderation("ModerationRequests", "OriginalSubject", ProtectedFieldClassification.Sensitive); + Moderation("ModerationRequests", "OriginalText", ProtectedFieldClassification.Sensitive); + Moderation("ModerationRequests", "OriginalFileName", ProtectedFieldClassification.Sensitive); + Moderation("ModerationRequests", "OriginalContentType", ProtectedFieldClassification.Sensitive); + Moderation("ModerationRequests", "OriginalContent", ProtectedFieldClassification.Sensitive, + ProtectedFieldStorageKind.Binary); + Moderation("ModerationRequests", "OriginalMetadataJson", ProtectedFieldClassification.Sensitive); + Moderation("ModerationRequests", "AdminNote", ProtectedFieldClassification.Sensitive); + + Moderation("ModerationReports", "Note", ProtectedFieldClassification.Sensitive); + + Moderation("ModerationActions", "Note", ProtectedFieldClassification.Sensitive); + Moderation("ModerationActions", "DetailsJson", ProtectedFieldClassification.Sensitive); + Moderation("ModerationActions", "EvidenceText", ProtectedFieldClassification.Sensitive); + Moderation("ModerationActions", "EvidenceContent", ProtectedFieldClassification.Sensitive, + ProtectedFieldStorageKind.Binary); + Moderation("ModerationActions", "EvidenceMetadataJson", ProtectedFieldClassification.Sensitive); + + // Chat moderation carries the same classification (plan 5.3): flag and action notes, + // reasons, detail JSON, and the export payload — an export is the whole conversation. + Moderation("ChatMessageFlags", "Note", ProtectedFieldClassification.Sensitive); + Moderation("ChatMessageFlags", "ResolutionNote", ProtectedFieldClassification.Sensitive); + + Moderation("ChatModerationActions", "Reason", ProtectedFieldClassification.Sensitive); + Moderation("ChatModerationActions", "DetailsJson", ProtectedFieldClassification.Sensitive); + + Moderation("ChatExports", "Data", ProtectedFieldClassification.Sensitive, + ProtectedFieldStorageKind.Binary); + Moderation("ChatExports", "Error", ProtectedFieldClassification.Sensitive); + + // ---- The plan's remaining candidates (sections 5.2 and 22.1), catalog v9 ----------- + void Remaining(string family, string table, string column, ProtectedFieldClassification classification, + PermissionTypes permission, ProtectedFieldStorageKind kind = ProtectedFieldStorageKind.Text) => + list.Add(new ProtectedFieldDefinition($"{table.ToLowerInvariant()}.{column.ToLowerInvariant()}", + family, table, column, kind, classification, permission, permission, + RemainingCandidatesCatalogVersion)); + + // A unit log narrative is the crew's own account of a response, and a user state note is + // why someone is unavailable ("at the hospital with my father") - both free text about + // people, which the plan defaults to sensitive in a protected department. + Remaining(OperationalFamily, "UnitLogs", "Narrative", ProtectedFieldClassification.Sensitive, + PermissionTypes.ViewProtectedOperationalData); + Remaining(OperationalFamily, "UserStates", "Note", ProtectedFieldClassification.Sensitive, + PermissionTypes.ViewProtectedOperationalData); + + // Calendar entries name people and places: "Meet family re: incident 4471", a home + // address as the location. Structural scheduling columns (start/end, timezone, + // recurrence rule) stay plaintext - a protected department's calendar must still lay out. + Remaining(OperationalFamily, "CalendarItems", "Title", ProtectedFieldClassification.Sensitive, + PermissionTypes.ViewProtectedOperationalData); + Remaining(OperationalFamily, "CalendarItems", "Description", ProtectedFieldClassification.Sensitive, + PermissionTypes.ViewProtectedOperationalData); + Remaining(OperationalFamily, "CalendarItems", "Location", ProtectedFieldClassification.Sensitive, + PermissionTypes.ViewProtectedOperationalData); + + // A department document is whatever they uploaded - protocols, but also incident + // paperwork and personnel letters. The FILE is the point, so it is cataloged with its + // name: protecting the metadata while serving the bytes in the clear protects nothing. + Remaining(DocumentsFamily, "Documents", "Name", ProtectedFieldClassification.Sensitive, + PermissionTypes.ViewProtectedOperationalData); + Remaining(DocumentsFamily, "Documents", "Description", ProtectedFieldClassification.Sensitive, + PermissionTypes.ViewProtectedOperationalData); + Remaining(DocumentsFamily, "Documents", "Filename", ProtectedFieldClassification.Sensitive, + PermissionTypes.ViewProtectedOperationalData); + Remaining(DocumentsFamily, "Documents", "Data", ProtectedFieldClassification.Sensitive, + PermissionTypes.ViewProtectedOperationalData, ProtectedFieldStorageKind.Binary); + + // Section 22.1 credential hygiene: a stored mailbox username and password sitting in the + // clear is a standing credential leak. Nothing in the codebase reads these columns today + // (the import paths take their configuration elsewhere), so binding them costs nothing — + // but a FUTURE consumer would need an attended path or a workload-decryptable secret + // store, because the broker's decrypt lane is grant-gated by design. + Remaining(CredentialsFamily, "DistributionLists", "Username", ProtectedFieldClassification.Sensitive, + PermissionTypes.ManageDepartmentDataProtection); + Remaining(CredentialsFamily, "DistributionLists", "Password", ProtectedFieldClassification.Sensitive, + PermissionTypes.ManageDepartmentDataProtection); + return list; } } diff --git a/Core/Resgrid.Services/ProtectedProjectionService.cs b/Core/Resgrid.Services/ProtectedProjectionService.cs index 8643c6ec9..1393baaa7 100644 --- a/Core/Resgrid.Services/ProtectedProjectionService.cs +++ b/Core/Resgrid.Services/ProtectedProjectionService.cs @@ -6,6 +6,7 @@ using Newtonsoft.Json.Linq; using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Localization.Areas.User.SystemMessages; using Resgrid.Model.Services; namespace Resgrid.Services @@ -96,9 +97,16 @@ public async Task BuildSafeWorkflowPayloadAsync(int departmentId, object /// /// The exact generic line the plan mandates for GenericOnly egress (section 9.1). /// + /// + /// English fallback for the sanitized dispatch text. The value members actually receive is + /// resolved per recipient through SystemMessagesResources; this constant remains for the + /// surfaces that have no recipient at all (the shared web link page) and as the resource + /// default. + /// 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) + public async Task BuildNotificationSafeCallAsync(int departmentId, Call call, ProtectedDataEgressChannel channel, + string culture = null) { if (call == null) return null; @@ -143,11 +151,77 @@ public async Task BuildNotificationSafeCallAsync(int departmentId, Call ca State = call.State, IsCritical = call.IsCritical, LoggedOn = call.LoggedOn, - Name = string.IsNullOrWhiteSpace(call.Number) ? "Protected dispatch" : call.Number, - NatureOfCall = GenericDispatchText + Name = string.IsNullOrWhiteSpace(call.Number) + ? SystemMessagesResources.Get("AdpProtectedDispatchName", culture) + : call.Number, + NatureOfCall = SystemMessagesResources.Get("AdpProtectedDispatchNotice", culture) }; } + public async Task BuildNotificationSafeMessageAsync(int departmentId, Message message, + ProtectedDataEgressChannel channel, string culture = null) + { + if (message == 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} message notification defensively."); + enforced = true; + } + + if (!enforced) + return message; + + // Same rule as dispatches: an AllowProtectedContent channel may carry the real message + // only while it is actually plaintext. Once the row is enveloped there is nothing the + // notification host can do with it but forward ciphertext. + if (await ChannelAllowsProtectedContentAsync(departmentId, channel) && !HasAnyEnvelopedMessageField(message)) + return message; + + // Sanitized clone: routing and structure survive, content does not. Type is preserved + // because delivery branches on it (weather alerts are email/push only), and the read + // state because the inbox badge is computed from it. + return new Message + { + MessageId = message.MessageId, + DepartmentId = message.DepartmentId, + SendingUserId = message.SendingUserId, + SendingUser = message.SendingUser, + ReceivingUserId = message.ReceivingUserId, + ReceivingUser = message.ReceivingUser, + SystemGenerated = message.SystemGenerated, + Type = message.Type, + SentOn = message.SentOn, + ReadOn = message.ReadOn, + ExpireOn = message.ExpireOn, + IsBroadcast = message.IsBroadcast, + Subject = SystemMessagesResources.Get("AdpProtectedMessageSubject", culture), + Body = SystemMessagesResources.Get("AdpProtectedMessageNotice", culture) + }; + } + + /// + /// True when any cataloged Messages field carries an envelope prefix. Driven by the + /// parity-pinned accessor map so a catalog addition is covered without touching this guard. + /// + private static bool HasAnyEnvelopedMessageField(Message message) + { + foreach (var accessor in ProtectedReadService.MessageFieldAccessors) + { + if (ProtectedDataEnvelope.HasEnvelopePrefix(accessor.Value.Get(message))) + return true; + } + + return false; + } + /// /// True when any cataloged Calls field carries an envelope prefix. Driven by the parity-pinned /// accessor map so a catalog addition is covered without touching this guard. diff --git a/Core/Resgrid.Services/ProtectedReadService.cs b/Core/Resgrid.Services/ProtectedReadService.cs index 69ea5a6fc..cb5e18945 100644 --- a/Core/Resgrid.Services/ProtectedReadService.cs +++ b/Core/Resgrid.Services/ProtectedReadService.cs @@ -82,6 +82,13 @@ public class ProtectedReadService : IProtectedReadService, IProtectedWriteServic /// The rgdpb binary attachment payload field id. public const string AttachmentDataFieldId = "callattachments.data"; + /// CallReferences text columns (parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> CallReferenceFieldAccessors = + new Dictionary, Action)> + { + ["callreferences.note"] = (r => r.Note, (r, v) => r.Note = v) + }; + /// /// CallLogs text columns (parity-pinned). CallLogs is the per-call running log, a different /// table and entity from the Log family's incident work logs — both happen to call their @@ -213,6 +220,145 @@ public class ProtectedReadService : IProtectedReadService, IProtectedWriteServic ["udffieldvalues.value"] = (v => v.Value, (v, x) => v.Value = x) }; + /// Messages text columns (catalog v7; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> MessageFieldAccessors = + new Dictionary, Action)> + { + ["messages.subject"] = (m => m.Subject, (m, v) => m.Subject = v), + ["messages.body"] = (m => m.Body, (m, v) => m.Body = v) + }; + + /// + /// MessageRecipients text columns (catalog v7; parity-pinned). PromptMetadata is NOT here and + /// never will be: every reader of that token runs without a grant. + /// + public static readonly IReadOnlyDictionary Get, Action Set)> MessageRecipientFieldAccessors = + new Dictionary, Action)> + { + ["messagerecipients.response"] = (r => r.Response, (r, v) => r.Response = v), + ["messagerecipients.note"] = (r => r.Note, (r, v) => r.Note = v) + }; + + /// MessageRecipients companion columns: the position a reply was filed from. + public static readonly IReadOnlyDictionary GetEnvelope, Action SetTyped)> MessageRecipientCompanionAccessors = + new Dictionary, Action)> + { + ["messagerecipients.latitude"] = (r => r.ProtectedLatitudeEnvelope, (r, v) => r.Latitude = v), + ["messagerecipients.longitude"] = (r => r.ProtectedLongitudeEnvelope, (r, v) => r.Longitude = v) + }; + + /// ModerationRequests text columns (catalog v8; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> ModerationRequestFieldAccessors = + new Dictionary, Action)> + { + ["moderationrequests.originalsubject"] = (r => r.OriginalSubject, (r, v) => r.OriginalSubject = v), + ["moderationrequests.originaltext"] = (r => r.OriginalText, (r, v) => r.OriginalText = v), + ["moderationrequests.originalfilename"] = (r => r.OriginalFileName, (r, v) => r.OriginalFileName = v), + ["moderationrequests.originalcontenttype"] = (r => r.OriginalContentType, (r, v) => r.OriginalContentType = v), + ["moderationrequests.originalmetadatajson"] = (r => r.OriginalMetadataJson, (r, v) => r.OriginalMetadataJson = v), + ["moderationrequests.adminnote"] = (r => r.AdminNote, (r, v) => r.AdminNote = v) + }; + + /// The rgdpb binary payload of the reported item. + public const string ModerationRequestContentFieldId = "moderationrequests.originalcontent"; + + /// ModerationReports text columns (catalog v8; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> ModerationReportFieldAccessors = + new Dictionary, Action)> + { + ["moderationreports.note"] = (r => r.Note, (r, v) => r.Note = v) + }; + + /// + /// ModerationActions text columns (catalog v8; parity-pinned). ActorRole/IpAddress/UserAgent/ + /// TraceId/ServerName are NOT here: they are the security audit trail of who acted and from + /// where, not the reported content (plan 5.4). + /// + public static readonly IReadOnlyDictionary Get, Action Set)> ModerationActionFieldAccessors = + new Dictionary, Action)> + { + ["moderationactions.note"] = (a => a.Note, (a, v) => a.Note = v), + ["moderationactions.detailsjson"] = (a => a.DetailsJson, (a, v) => a.DetailsJson = v), + ["moderationactions.evidencetext"] = (a => a.EvidenceText, (a, v) => a.EvidenceText = v), + ["moderationactions.evidencemetadatajson"] = (a => a.EvidenceMetadataJson, (a, v) => a.EvidenceMetadataJson = v) + }; + + /// The rgdpb binary evidence snapshot on a moderation action. + public const string ModerationActionEvidenceFieldId = "moderationactions.evidencecontent"; + + /// ChatMessageFlags text columns (catalog v8; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> ChatMessageFlagFieldAccessors = + new Dictionary, Action)> + { + ["chatmessageflags.note"] = (f => f.Note, (f, v) => f.Note = v), + ["chatmessageflags.resolutionnote"] = (f => f.ResolutionNote, (f, v) => f.ResolutionNote = v) + }; + + /// ChatModerationActions text columns (catalog v8; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> ChatModerationActionFieldAccessors = + new Dictionary, Action)> + { + ["chatmoderationactions.reason"] = (a => a.Reason, (a, v) => a.Reason = v), + ["chatmoderationactions.detailsjson"] = (a => a.DetailsJson, (a, v) => a.DetailsJson = v) + }; + + /// ChatExports text columns (catalog v8; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> ChatExportFieldAccessors = + new Dictionary, Action)> + { + ["chatexports.error"] = (e => e.Error, (e, v) => e.Error = v) + }; + + /// The rgdpb export payload — an entire conversation. + public const string ChatExportDataFieldId = "chatexports.data"; + + /// UnitLogs text columns (catalog v9; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> UnitLogFieldAccessors = + new Dictionary, Action)> + { + ["unitlogs.narrative"] = (l => l.Narrative, (l, v) => l.Narrative = v) + }; + + /// UserStates text columns (catalog v9; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> UserStateFieldAccessors = + new Dictionary, Action)> + { + ["userstates.note"] = (u => u.Note, (u, v) => u.Note = v) + }; + + /// + /// CalendarItems text columns (catalog v9; parity-pinned). The scheduling columns - start, + /// end, timezones, recurrence rule - are structural and stay plaintext, so a protected + /// department's calendar still lays out without a grant. + /// + public static readonly IReadOnlyDictionary Get, Action Set)> CalendarItemFieldAccessors = + new Dictionary, Action)> + { + ["calendaritems.title"] = (c => c.Title, (c, v) => c.Title = v), + ["calendaritems.description"] = (c => c.Description, (c, v) => c.Description = v), + ["calendaritems.location"] = (c => c.Location, (c, v) => c.Location = v) + }; + + /// Documents text columns (catalog v9; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> DocumentFieldAccessors = + new Dictionary, Action)> + { + ["documents.name"] = (d => d.Name, (d, v) => d.Name = v), + ["documents.description"] = (d => d.Description, (d, v) => d.Description = v), + ["documents.filename"] = (d => d.Filename, (d, v) => d.Filename = v) + }; + + /// The rgdpb document payload. + public const string DocumentDataFieldId = "documents.data"; + + /// DistributionLists stored mailbox credentials (catalog v9; parity-pinned). + public static readonly IReadOnlyDictionary Get, Action Set)> DistributionListFieldAccessors = + new Dictionary, Action)> + { + ["distributionlists.username"] = (d => d.Username, (d, v) => d.Username = v), + ["distributionlists.password"] = (d => d.Password, (d, v) => d.Password = v) + }; + private static readonly byte[] BinaryPrefixBytes = Encoding.ASCII.GetBytes(ProtectedDataEnvelope.BinaryPrefix); /// One protected value wired to its reveal/redact actions on the owning entity. @@ -273,6 +419,34 @@ public async Task> ResolveForReadAsync(int de if (result.Call.Attachments != null) foreach (var attachment in result.Call.Attachments.Where(a => a != null)) CollectAttachmentSlots(result, attachment, slots, includeData: false); + + // Linked-call reference notes ride the same batch. Resolving them here rather than in + // each controller means every surface that populates References is covered, including + // the linked-call editor that posts the note back from a hidden input. + if (result.Call.References != null) + { + foreach (var reference in result.Call.References.Where(r => r != null)) + { + foreach (var accessor in CallReferenceFieldAccessors) + { + var referenceValue = accessor.Value.Get(reference); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(referenceValue)) + continue; + + var set = accessor.Value.Set; + var target = reference; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = target.CallReferenceId, + WireValue = referenceValue, + Owner = result, + Reveal = plaintext => set(target, plaintext), + Redact = () => set(target, ProtectedDataEnvelope.RedactionValue) + }); + } + } + } } await ResolveSlotsAsync(departmentId, grantToken, userId, results, slots, cancellationToken); @@ -361,6 +535,44 @@ public async Task ResolveMemberEmergencyContactsForReadAsyn return result; } + /// + /// Applies the REDACTED-sentinel policy to a cataloged entity before it is encrypted, and + /// reports whether anything moved so the caller knows the row must be re-persisted. + /// + /// The sentinel is what a form posts back for a value the editor was never allowed to see. + /// It must NEVER reach the database: the write nets run after the row has already been + /// saved, so simply skipping the field — which nine of these paths used to do — leaves the + /// literal word "REDACTED" stored in place of the member's real data, and a later save then + /// encrypts that word and makes the loss permanent. + /// + /// Two outcomes, in order of preference: + /// * an existing row was supplied — restore the stored value, which is the real fix; + /// * no existing row — null the field, which still loses the edit but leaves the column + /// honestly empty rather than holding a fake value that looks like data. + /// + /// Callers that can supply the stored row should; this is the floor, not the ceiling. + /// + private static bool ApplySentinelPolicy(T entity, T existing, + IReadOnlyDictionary Get, Action Set)> accessors) + where T : class + { + if (entity == null) + return false; + + var changed = false; + + foreach (var accessor in accessors) + { + if (accessor.Value.Get(entity) != ProtectedDataEnvelope.RedactionValue) + continue; + + accessor.Value.Set(entity, existing != null ? accessor.Value.Get(existing) : null); + changed = true; + } + + return changed; + } + public async Task PrepareMemberEmergencyContactWriteAsync(int departmentId, DepartmentMemberEmergencyContact contact, string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) @@ -368,6 +580,11 @@ public async Task PrepareMemberEmergencyContactWriteAsync( if (contact == null) return ProtectedWriteResult.Allowed(); + // Sentinel policy first: a value the editor never had revealed must not survive into the + // row, and this net runs AFTER the entity was saved, so skipping it would leave the + // literal placeholder stored. + var sentinelsHandled = ApplySentinelPolicy(contact, null, MemberEmergencyContactAccessors); + var slots = new List(); var rowKey = contact.DepartmentMemberEmergencyContactId.ToString(CultureInfo.InvariantCulture); foreach (var accessor in MemberEmergencyContactAccessors) @@ -378,128 +595,729 @@ public async Task PrepareMemberEmergencyContactWriteAsync( continue; var set = accessor.Value.Set; - slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(contact, envelope) }); + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(contact, envelope) }); + } + + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => contact.IsProtected = true, cancellationToken); + + // A restore or neutralization mutates the entity without producing a broker slot, + // so EncryptSlotsAsync has nothing to report. The caller re-persists only on Changed. + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; + } + + public async Task ResolveMemberSensitiveDataForReadAsync(int departmentId, + IReadOnlyList rows, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var row in (rows ?? Array.Empty()).Where(r => r != null)) + { + var rowKey = row.DepartmentMemberSensitiveDataId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in MemberSensitiveDataAccessors) + { + var value = accessor.Value.Get(row); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = result, + Reveal = plaintext => set(row, plaintext), + Redact = () => set(row, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task PrepareMemberSensitiveDataWriteAsync(int departmentId, + DepartmentMemberSensitiveData data, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default) + { + if (data == null) + return ProtectedWriteResult.Allowed(); + + // Sentinel policy first: a value the editor never had revealed must not survive into the + // row, and this net runs AFTER the entity was saved, so skipping it would leave the + // literal placeholder stored. + var sentinelsHandled = ApplySentinelPolicy(data, null, MemberSensitiveDataAccessors); + + + var slots = new List(); + var rowKey = data.DepartmentMemberSensitiveDataId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in MemberSensitiveDataAccessors) + { + var value = accessor.Value.Get(data); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(data, envelope) }); + } + + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => data.IsProtected = true, cancellationToken); + + // A restore or neutralization mutates the entity without producing a broker slot, + // so EncryptSlotsAsync has nothing to report. The caller re-persists only on Changed. + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; + } + + public async Task ResolveCallReferencesForReadAsync(int departmentId, + IReadOnlyList references, string grantToken, string userId, + CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var reference in (references ?? Array.Empty()).Where(r => r != null)) + { + foreach (var accessor in CallReferenceFieldAccessors) + { + var value = accessor.Value.Get(reference); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = reference.CallReferenceId, + WireValue = value, + Owner = result, + Reveal = plaintext => set(reference, plaintext), + Redact = () => set(reference, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveMessagesForReadAsync(int departmentId, + IReadOnlyList messages, string grantToken, string userId, + CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + + foreach (var message in (messages ?? Array.Empty()).Where(m => m != null)) + { + CollectMessageSlots(result, message, slots); + + // Recipients ride the same batch when they are populated, exactly as call notes and + // attachments do: revealing the body while the replies keep showing placeholders + // would be a half-reveal of one conversation. + if (message.MessageRecipients != null) + { + foreach (var recipient in message.MessageRecipients.Where(r => r != null)) + CollectMessageRecipientSlots(result, recipient, slots); + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveMessageRecipientsForReadAsync(int departmentId, + IReadOnlyList recipients, string grantToken, string userId, + CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var recipient in (recipients ?? Array.Empty()).Where(r => r != null)) + CollectMessageRecipientSlots(result, recipient, slots); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveModerationRequestsForReadAsync(int departmentId, + IReadOnlyList requests, string grantToken, string userId, + bool includeContent = false, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + + foreach (var request in (requests ?? Array.Empty()).Where(r => r != null)) + { + var rowKey = request.ModerationRequestId; + CollectTextSlots(result, slots, ModerationRequestFieldAccessors, request, rowKey); + + // The reported file rides along only when the caller asked for it; a queue listing + // strips the bytes so a serializer can never carry ciphertext it will not decrypt. + CollectBinarySlot(result, slots, ModerationRequestContentFieldId, rowKey, + request.OriginalContent, includeContent, + bytes => request.OriginalContent = bytes); + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveModerationReportsForReadAsync(int departmentId, + IReadOnlyList reports, string grantToken, string userId, + CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + + foreach (var report in (reports ?? Array.Empty()).Where(r => r != null)) + CollectTextSlots(result, slots, ModerationReportFieldAccessors, report, report.ModerationReportId); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveModerationActionsForReadAsync(int departmentId, + IReadOnlyList actions, string grantToken, string userId, + bool includeContent = false, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + + foreach (var action in (actions ?? Array.Empty()).Where(a => a != null)) + { + var rowKey = action.ModerationActionId; + CollectTextSlots(result, slots, ModerationActionFieldAccessors, action, rowKey); + CollectBinarySlot(result, slots, ModerationActionEvidenceFieldId, rowKey, + action.EvidenceContent, includeContent, + bytes => action.EvidenceContent = bytes); + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveChatMessageFlagsForReadAsync(int departmentId, + IReadOnlyList flags, string grantToken, string userId, + CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + + foreach (var flag in (flags ?? Array.Empty()).Where(f => f != null)) + CollectTextSlots(result, slots, ChatMessageFlagFieldAccessors, flag, flag.ChatMessageFlagId); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveChatModerationActionsForReadAsync(int departmentId, + IReadOnlyList actions, string grantToken, string userId, + CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + + foreach (var action in (actions ?? Array.Empty()).Where(a => a != null)) + CollectTextSlots(result, slots, ChatModerationActionFieldAccessors, action, action.ChatModerationActionId); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveChatExportsForReadAsync(int departmentId, + IReadOnlyList exports, string grantToken, string userId, + bool includeData = false, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + + foreach (var export in (exports ?? Array.Empty()).Where(e => e != null)) + { + var rowKey = export.ChatExportId; + CollectTextSlots(result, slots, ChatExportFieldAccessors, export, rowKey); + CollectBinarySlot(result, slots, ChatExportDataFieldId, rowKey, export.Data, includeData, + bytes => export.Data = bytes); + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveUnitLogsForReadAsync(int departmentId, + IReadOnlyList logs, string grantToken, string userId, + CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + + foreach (var log in (logs ?? Array.Empty()).Where(l => l != null)) + CollectTextSlots(result, slots, UnitLogFieldAccessors, log, log.UnitLogId.ToString(CultureInfo.InvariantCulture)); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveUserStatesForReadAsync(int departmentId, + IReadOnlyList states, string grantToken, string userId, + CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + + foreach (var state in (states ?? Array.Empty()).Where(x => x != null)) + CollectTextSlots(result, slots, UserStateFieldAccessors, state, state.UserStateId.ToString(CultureInfo.InvariantCulture)); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveCalendarItemsForReadAsync(int departmentId, + IReadOnlyList items, string grantToken, string userId, + CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + + foreach (var item in (items ?? Array.Empty()).Where(i => i != null)) + CollectTextSlots(result, slots, CalendarItemFieldAccessors, item, item.CalendarItemId.ToString(CultureInfo.InvariantCulture)); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveDocumentsForReadAsync(int departmentId, + IReadOnlyList documents, string grantToken, string userId, bool includeData = false, + CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + + foreach (var document in (documents ?? Array.Empty()).Where(d => d != null)) + { + var rowKey = document.DocumentId.ToString(CultureInfo.InvariantCulture); + CollectTextSlots(result, slots, DocumentFieldAccessors, document, rowKey); + CollectBinarySlot(result, slots, DocumentDataFieldId, rowKey, document.Data, includeData, + bytes => document.Data = bytes); + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveDistributionListsForReadAsync(int departmentId, + IReadOnlyList lists, string grantToken, string userId, + CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + + foreach (var list in (lists ?? Array.Empty()).Where(l => l != null)) + CollectTextSlots(result, slots, DistributionListFieldAccessors, list, + list.DistributionListId.ToString(CultureInfo.InvariantCulture)); + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveCallLogsForReadAsync(int departmentId, + IReadOnlyList logs, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var log in (logs ?? Array.Empty()).Where(l => l != null)) + { + var rowKey = log.CallLogId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in CallLogFieldAccessors) + { + var value = accessor.Value.Get(log); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = result, + Reveal = plaintext => set(log, plaintext), + Redact = () => set(log, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task ResolveLogsForReadAsync(int departmentId, + IReadOnlyList logs, string grantToken, string userId, CancellationToken cancellationToken = default) + { + var result = new ProtectedReadResult(); + var slots = new List(); + foreach (var log in (logs ?? Array.Empty()).Where(l => l != null)) + { + var rowKey = log.LogId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in LogFieldAccessors) + { + var value = accessor.Value.Get(log); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = result, + Reveal = plaintext => set(log, plaintext), + Redact = () => set(log, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); + return result; + } + + public async Task PrepareCallReferenceWriteAsync(int departmentId, + CallReference reference, CallReference existingReference, string grantToken, string userId, + bool workloadCaller, CancellationToken cancellationToken = default) + { + if (reference == null) + return ProtectedWriteResult.Allowed(); + + // Sentinel policy first: the linked-call editor round-trips the note through a hidden + // input, so an editor without a grant posts the placeholder straight back. + var sentinelsHandled = ApplySentinelPolicy(reference, existingReference, CallReferenceFieldAccessors); + + var slots = new List(); + foreach (var accessor in CallReferenceFieldAccessors) + { + var value = accessor.Value.Get(reference); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot + { + FieldId = accessor.Key, + RowKey = reference.CallReferenceId, + WireValue = value, + Apply = envelope => set(reference, envelope) + }); + } + + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; + } + + public async Task PrepareMessageWriteAsync(int departmentId, Message message, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (message == null) + return ProtectedWriteResult.Allowed(); + + var sentinelsHandled = ApplySentinelPolicy(message, null, MessageFieldAccessors); + + var slots = new List(); + var rowKey = message.MessageId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in MessageFieldAccessors) + { + var value = accessor.Value.Get(message); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(message, envelope) }); + } + + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; + } + + public async Task PrepareMessageRecipientWriteAsync(int departmentId, + MessageRecipient recipient, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default) + { + if (recipient == null) + return ProtectedWriteResult.Allowed(); + + var sentinelsHandled = ApplySentinelPolicy(recipient, null, MessageRecipientFieldAccessors); + + var slots = new List(); + var rowKey = recipient.MessageRecipientId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in MessageRecipientFieldAccessors) + { + var value = accessor.Value.Get(recipient); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(recipient, envelope) }); + } + + // Companion columns: the typed coordinate moves into its envelope column and the typed + // column is nulled - the migration engine's exact write shape (plan 22.3). + if (recipient.Latitude.HasValue) + slots.Add(new WriteSlot + { + FieldId = "messagerecipients.latitude", + RowKey = rowKey, + WireValue = recipient.Latitude.Value.ToString(CultureInfo.InvariantCulture), + Apply = envelope => { recipient.ProtectedLatitudeEnvelope = envelope; recipient.Latitude = null; } + }); + if (recipient.Longitude.HasValue) + slots.Add(new WriteSlot + { + FieldId = "messagerecipients.longitude", + RowKey = rowKey, + WireValue = recipient.Longitude.Value.ToString(CultureInfo.InvariantCulture), + Apply = envelope => { recipient.ProtectedLongitudeEnvelope = envelope; recipient.Longitude = null; } + }); + + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => recipient.IsProtected = true, cancellationToken); + + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; + } + + public async Task PrepareModerationRequestWriteAsync(int departmentId, + ModerationRequest request, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default) + { + if (request == null) + return ProtectedWriteResult.Allowed(); + + var sentinelsHandled = ApplySentinelPolicy(request, null, ModerationRequestFieldAccessors); + var slots = CollectTextWriteSlots(ModerationRequestFieldAccessors, request, request.ModerationRequestId); + + AddBinaryWriteSlot(slots, ModerationRequestContentFieldId, request.ModerationRequestId, + request.OriginalContent, bytes => request.OriginalContent = bytes); + + return await FinishModerationWriteAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => request.IsProtected = true, sentinelsHandled, cancellationToken); + } + + public async Task PrepareModerationReportWriteAsync(int departmentId, + ModerationReport report, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default) + { + if (report == null) + return ProtectedWriteResult.Allowed(); + + var sentinelsHandled = ApplySentinelPolicy(report, null, ModerationReportFieldAccessors); + var slots = CollectTextWriteSlots(ModerationReportFieldAccessors, report, report.ModerationReportId); + + return await FinishModerationWriteAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => report.IsProtected = true, sentinelsHandled, cancellationToken); + } + + public async Task PrepareModerationActionWriteAsync(int departmentId, + ModerationAction action, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default) + { + if (action == null) + return ProtectedWriteResult.Allowed(); + + var sentinelsHandled = ApplySentinelPolicy(action, null, ModerationActionFieldAccessors); + var slots = CollectTextWriteSlots(ModerationActionFieldAccessors, action, action.ModerationActionId); + + AddBinaryWriteSlot(slots, ModerationActionEvidenceFieldId, action.ModerationActionId, + action.EvidenceContent, bytes => action.EvidenceContent = bytes); + + return await FinishModerationWriteAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => action.IsProtected = true, sentinelsHandled, cancellationToken); + } + + public async Task PrepareChatMessageFlagWriteAsync(int departmentId, + ChatMessageFlag flag, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default) + { + if (flag == null) + return ProtectedWriteResult.Allowed(); + + var sentinelsHandled = ApplySentinelPolicy(flag, null, ChatMessageFlagFieldAccessors); + var slots = CollectTextWriteSlots(ChatMessageFlagFieldAccessors, flag, flag.ChatMessageFlagId); + + return await FinishModerationWriteAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => flag.IsProtected = true, sentinelsHandled, cancellationToken); + } + + public async Task PrepareChatModerationActionWriteAsync(int departmentId, + ChatModerationAction action, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default) + { + if (action == null) + return ProtectedWriteResult.Allowed(); + + var sentinelsHandled = ApplySentinelPolicy(action, null, ChatModerationActionFieldAccessors); + var slots = CollectTextWriteSlots(ChatModerationActionFieldAccessors, action, action.ChatModerationActionId); + + return await FinishModerationWriteAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => action.IsProtected = true, sentinelsHandled, cancellationToken); + } + + public async Task PrepareChatExportWriteAsync(int departmentId, ChatExport export, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (export == null) + return ProtectedWriteResult.Allowed(); + + var sentinelsHandled = ApplySentinelPolicy(export, null, ChatExportFieldAccessors); + var slots = CollectTextWriteSlots(ChatExportFieldAccessors, export, export.ChatExportId); + + AddBinaryWriteSlot(slots, ChatExportDataFieldId, export.ChatExportId, export.Data, + bytes => export.Data = bytes); + + return await FinishModerationWriteAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => export.IsProtected = true, sentinelsHandled, cancellationToken); + } + + /// Every plaintext cataloged text column of one row, as write slots. + private static List CollectTextWriteSlots( + IReadOnlyDictionary Get, Action Set)> accessors, T entity, string rowKey) + where T : class + { + var slots = new List(); + foreach (var accessor in accessors) + { + var value = accessor.Value.Get(entity); + if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || + value == ProtectedDataEnvelope.RedactionValue) + continue; + + var set = accessor.Value.Set; + slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(entity, envelope) }); } - return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, - () => contact.IsProtected = true, cancellationToken); + return slots; } - public async Task ResolveMemberSensitiveDataForReadAsync(int departmentId, - IReadOnlyList rows, string grantToken, string userId, CancellationToken cancellationToken = default) + private static void AddBinaryWriteSlot(List slots, string fieldId, string rowKey, + byte[] payload, Action apply) { - var result = new ProtectedReadResult(); - var slots = new List(); - foreach (var row in (rows ?? Array.Empty()).Where(r => r != null)) + if (payload == null || payload.Length == 0 || IsBinaryEnveloped(payload)) + return; + + slots.Add(new WriteSlot { - var rowKey = row.DepartmentMemberSensitiveDataId.ToString(CultureInfo.InvariantCulture); - foreach (var accessor in MemberSensitiveDataAccessors) - { - var value = accessor.Value.Get(row); - if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) - continue; + FieldId = fieldId, + RowKey = rowKey, + IsBinary = true, + WireValue = Convert.ToBase64String(payload), + Apply = envelope => apply(Convert.FromBase64String(envelope)) + }); + } - var set = accessor.Value.Set; - slots.Add(new Slot - { - FieldId = accessor.Key, - RowKey = rowKey, - WireValue = value, - Owner = result, - Reveal = plaintext => set(row, plaintext), - Redact = () => set(row, ProtectedDataEnvelope.RedactionValue) - }); - } - } + private async Task FinishModerationWriteAsync(int departmentId, string grantToken, + string userId, bool workloadCaller, List slots, Action markProtected, bool sentinelsHandled, + CancellationToken cancellationToken) + { + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + markProtected, cancellationToken); + + // A restore or neutralization mutates the entity without producing a broker slot, so + // EncryptSlotsAsync has nothing to report and the caller would not re-persist. + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; - await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); return result; } - public async Task PrepareMemberSensitiveDataWriteAsync(int departmentId, - DepartmentMemberSensitiveData data, string grantToken, string userId, bool workloadCaller, - CancellationToken cancellationToken = default) + public async Task PrepareUnitLogWriteAsync(int departmentId, UnitLog log, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) { - if (data == null) + if (log == null) return ProtectedWriteResult.Allowed(); - var slots = new List(); - var rowKey = data.DepartmentMemberSensitiveDataId.ToString(CultureInfo.InvariantCulture); - foreach (var accessor in MemberSensitiveDataAccessors) - { - var value = accessor.Value.Get(data); - if (string.IsNullOrEmpty(value) || ProtectedDataEnvelope.HasEnvelopePrefix(value) || - value == ProtectedDataEnvelope.RedactionValue) - continue; + var sentinelsHandled = ApplySentinelPolicy(log, null, UnitLogFieldAccessors); + var slots = CollectTextWriteSlots(UnitLogFieldAccessors, log, log.UnitLogId.ToString(CultureInfo.InvariantCulture)); - var set = accessor.Value.Set; - slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(data, envelope) }); - } + return await FinishModerationWriteAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => log.IsProtected = true, sentinelsHandled, cancellationToken); + } - return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, - () => data.IsProtected = true, cancellationToken); + public async Task PrepareUserStateWriteAsync(int departmentId, UserState state, + string grantToken, string userId, bool workloadCaller, CancellationToken cancellationToken = default) + { + if (state == null) + return ProtectedWriteResult.Allowed(); + + var sentinelsHandled = ApplySentinelPolicy(state, null, UserStateFieldAccessors); + var slots = CollectTextWriteSlots(UserStateFieldAccessors, state, state.UserStateId.ToString(CultureInfo.InvariantCulture)); + + return await FinishModerationWriteAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => state.IsProtected = true, sentinelsHandled, cancellationToken); } - public async Task ResolveCallLogsForReadAsync(int departmentId, - IReadOnlyList logs, string grantToken, string userId, CancellationToken cancellationToken = default) + public async Task PrepareCalendarItemWriteAsync(int departmentId, CalendarItem item, + CalendarItem existingItem, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default) { - var result = new ProtectedReadResult(); - var slots = new List(); - foreach (var log in (logs ?? Array.Empty()).Where(l => l != null)) - { - var rowKey = log.CallLogId.ToString(CultureInfo.InvariantCulture); - foreach (var accessor in CallLogFieldAccessors) - { - var value = accessor.Value.Get(log); - if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) - continue; + if (item == null) + return ProtectedWriteResult.Allowed(); - var set = accessor.Value.Set; - slots.Add(new Slot - { - FieldId = accessor.Key, - RowKey = rowKey, - WireValue = value, - Owner = result, - Reveal = plaintext => set(log, plaintext), - Redact = () => set(log, ProtectedDataEnvelope.RedactionValue) - }); - } - } + // A calendar item IS edited through a form, so a concealed value can be posted back as + // the placeholder; the stored row restores it. + var sentinelsHandled = ApplySentinelPolicy(item, existingItem, CalendarItemFieldAccessors); + var slots = CollectTextWriteSlots(CalendarItemFieldAccessors, item, item.CalendarItemId.ToString(CultureInfo.InvariantCulture)); - await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); - return result; + return await FinishModerationWriteAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => item.IsProtected = true, sentinelsHandled, cancellationToken); } - public async Task ResolveLogsForReadAsync(int departmentId, - IReadOnlyList logs, string grantToken, string userId, CancellationToken cancellationToken = default) + public async Task PrepareDocumentWriteAsync(int departmentId, Document document, + Document existingDocument, string grantToken, string userId, bool workloadCaller, + CancellationToken cancellationToken = default) { - var result = new ProtectedReadResult(); - var slots = new List(); - foreach (var log in (logs ?? Array.Empty()).Where(l => l != null)) - { - var rowKey = log.LogId.ToString(CultureInfo.InvariantCulture); - foreach (var accessor in LogFieldAccessors) - { - var value = accessor.Value.Get(log); - if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) - continue; + if (document == null) + return ProtectedWriteResult.Allowed(); - var set = accessor.Value.Set; - slots.Add(new Slot - { - FieldId = accessor.Key, - RowKey = rowKey, - WireValue = value, - Owner = result, - Reveal = plaintext => set(log, plaintext), - Redact = () => set(log, ProtectedDataEnvelope.RedactionValue) - }); - } - } + var sentinelsHandled = ApplySentinelPolicy(document, existingDocument, DocumentFieldAccessors); + var rowKey = document.DocumentId.ToString(CultureInfo.InvariantCulture); + var slots = CollectTextWriteSlots(DocumentFieldAccessors, document, rowKey); - await ResolveSlotsAsync(departmentId, grantToken, userId, new List { result }, slots, cancellationToken); - return result; + AddBinaryWriteSlot(slots, DocumentDataFieldId, rowKey, document.Data, bytes => document.Data = bytes); + + return await FinishModerationWriteAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => document.IsProtected = true, sentinelsHandled, cancellationToken); + } + + public async Task PrepareDistributionListWriteAsync(int departmentId, + DistributionList list, DistributionList existingList, string grantToken, string userId, + bool workloadCaller, CancellationToken cancellationToken = default) + { + if (list == null) + return ProtectedWriteResult.Allowed(); + + var sentinelsHandled = ApplySentinelPolicy(list, existingList, DistributionListFieldAccessors); + var slots = CollectTextWriteSlots(DistributionListFieldAccessors, list, + list.DistributionListId.ToString(CultureInfo.InvariantCulture)); + + return await FinishModerationWriteAsync(departmentId, grantToken, userId, workloadCaller, slots, + () => list.IsProtected = true, sentinelsHandled, cancellationToken); } public async Task PrepareCallLogWriteAsync(int departmentId, CallLog log, @@ -508,6 +1326,11 @@ public async Task PrepareCallLogWriteAsync(int departmentI if (log == null) return ProtectedWriteResult.Allowed(); + // Sentinel policy first: a value the editor never had revealed must not survive into the + // row, and this net runs AFTER the entity was saved, so skipping it would leave the + // literal placeholder stored. + var sentinelsHandled = ApplySentinelPolicy(log, null, CallLogFieldAccessors); + var slots = new List(); var rowKey = log.CallLogId.ToString(CultureInfo.InvariantCulture); foreach (var accessor in CallLogFieldAccessors) @@ -523,7 +1346,14 @@ public async Task PrepareCallLogWriteAsync(int departmentI // No marker column on CallLogs, so nothing to flag — the sweep and the residue counts // work off envelope detection on the column itself. - return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + + // A restore or neutralization mutates the entity without producing a broker slot, + // so EncryptSlotsAsync has nothing to report. The caller re-persists only on Changed. + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; } public async Task PrepareLogWriteAsync(int departmentId, Log log, @@ -532,6 +1362,11 @@ public async Task PrepareLogWriteAsync(int departmentId, L if (log == null) return ProtectedWriteResult.Allowed(); + // Sentinel policy first: a value the editor never had revealed must not survive into the + // row, and this net runs AFTER the entity was saved, so skipping it would leave the + // literal placeholder stored. + var sentinelsHandled = ApplySentinelPolicy(log, null, LogFieldAccessors); + var slots = new List(); var rowKey = log.LogId.ToString(CultureInfo.InvariantCulture); foreach (var accessor in LogFieldAccessors) @@ -545,7 +1380,14 @@ public async Task PrepareLogWriteAsync(int departmentId, L slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(log, envelope) }); } - return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + + // A restore or neutralization mutates the entity without producing a broker slot, + // so EncryptSlotsAsync has nothing to report. The caller re-persists only on Changed. + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; } public async Task ResolveUnitStatesForReadAsync(int departmentId, @@ -701,6 +1543,12 @@ public async Task PrepareCallWriteAsync(int departmentId, if (call == null) return ProtectedWriteResult.Allowed(); + // Sentinel policy first: a value the editor never had revealed must not survive into the + // row, and this net runs AFTER the entity was saved, so skipping it would leave the + // literal placeholder stored. + var sentinelsHandled = ApplySentinelPolicy(call, existingCall, CallFieldAccessors); + + var slots = new List(); var rowKey = call.CallId.ToString(CultureInfo.InvariantCulture); foreach (var accessor in CallFieldAccessors) @@ -731,7 +1579,14 @@ public async Task PrepareCallWriteAsync(int departmentId, }); } - return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + + // A restore or neutralization mutates the entity without producing a broker slot, + // so EncryptSlotsAsync has nothing to report. The caller re-persists only on Changed. + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; } public async Task PrepareCallNoteWriteAsync(int departmentId, CallNote note, @@ -740,6 +1595,11 @@ public async Task PrepareCallNoteWriteAsync(int department if (note == null) return ProtectedWriteResult.Allowed(); + // Sentinel policy first: a value the editor never had revealed must not survive into the + // row, and this net runs AFTER the entity was saved, so skipping it would leave the + // literal placeholder stored. + var sentinelsHandled = ApplySentinelPolicy(note, null, NoteFieldAccessors); + var slots = new List(); var rowKey = note.CallNoteId.ToString(CultureInfo.InvariantCulture); foreach (var accessor in NoteFieldAccessors) @@ -772,8 +1632,15 @@ public async Task PrepareCallNoteWriteAsync(int department Apply = envelope => { note.ProtectedLongitudeEnvelope = envelope; note.Longitude = null; } }); - return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, () => note.IsProtected = true, cancellationToken); + + // A restore or neutralization mutates the entity without producing a broker slot, + // so EncryptSlotsAsync has nothing to report. The caller re-persists only on Changed. + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; } public async Task PrepareCallAttachmentWriteAsync(int departmentId, CallAttachment attachment, @@ -782,6 +1649,11 @@ public async Task PrepareCallAttachmentWriteAsync(int depa if (attachment == null) return ProtectedWriteResult.Allowed(); + // Sentinel policy first: a value the editor never had revealed must not survive into the + // row, and this net runs AFTER the entity was saved, so skipping it would leave the + // literal placeholder stored. + var sentinelsHandled = ApplySentinelPolicy(attachment, null, AttachmentFieldAccessors); + var slots = new List(); var rowKey = attachment.CallAttachmentId.ToString(CultureInfo.InvariantCulture); foreach (var accessor in AttachmentFieldAccessors) @@ -822,8 +1694,15 @@ public async Task PrepareCallAttachmentWriteAsync(int depa Apply = envelopeBase64 => attachment.Data = Convert.FromBase64String(envelopeBase64) }); - return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, () => attachment.IsProtected = true, cancellationToken); + + // A restore or neutralization mutates the entity without producing a broker slot, + // so EncryptSlotsAsync has nothing to report. The caller re-persists only on Changed. + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; } public async Task PrepareCertificationWriteAsync(int departmentId, @@ -833,8 +1712,13 @@ public async Task PrepareCertificationWriteAsync(int depar if (certification == null) return ProtectedWriteResult.Allowed(); + // Sentinel policy first: a value the editor never had revealed must not survive into the + // row, and this net runs AFTER the entity was saved, so skipping it would leave the + // literal placeholder stored. + var sentinelsHandled = ApplySentinelPolicy(certification, existingCertification, CertificationFieldAccessors); + + var slots = new List(); - var restored = false; var rowKey = certification.PersonnelCertificationId.ToString(CultureInfo.InvariantCulture); foreach (var accessor in CertificationFieldAccessors) { @@ -845,10 +1729,7 @@ public async Task PrepareCertificationWriteAsync(int depar if (value == ProtectedDataEnvelope.RedactionValue) { if (existingCertification != null) - { accessor.Value.Set(certification, accessor.Value.Get(existingCertification)); - restored = true; - } continue; } @@ -883,7 +1764,7 @@ public async Task PrepareCertificationWriteAsync(int depar // without this the transiently-saved row keeps the literal REDACTED placeholder and the // member's certification number and file name are gone. Reported through the result // rather than left to each caller to special-case, the way calls and contacts do. - if (restored && result.Success && !result.Changed) + if (sentinelsHandled && result.Success && !result.Changed) result.Changed = true; return result; @@ -895,6 +1776,11 @@ public async Task PrepareContactWriteAsync(int departmentI if (contact == null) return ProtectedWriteResult.Allowed(); + // Sentinel policy first: a value the editor never had revealed must not survive into the + // row, and this net runs AFTER the entity was saved, so skipping it would leave the + // literal placeholder stored. + var sentinelsHandled = ApplySentinelPolicy(contact, existingContact, ContactFieldAccessors); + var slots = new List(); var rowKey = contact.ContactId; foreach (var accessor in ContactFieldAccessors) @@ -925,7 +1811,14 @@ public async Task PrepareContactWriteAsync(int departmentI Apply = envelopeBase64 => contact.Image = Convert.FromBase64String(envelopeBase64) }); - return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + + // A restore or neutralization mutates the entity without producing a broker slot, + // so EncryptSlotsAsync has nothing to report. The caller re-persists only on Changed. + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; } public async Task PrepareContactNoteWriteAsync(int departmentId, ContactNote note, @@ -934,6 +1827,11 @@ public async Task PrepareContactNoteWriteAsync(int departm if (note == null) return ProtectedWriteResult.Allowed(); + // Sentinel policy first: a value the editor never had revealed must not survive into the + // row, and this net runs AFTER the entity was saved, so skipping it would leave the + // literal placeholder stored. + var sentinelsHandled = ApplySentinelPolicy(note, null, ContactNoteFieldAccessors); + var slots = new List(); var rowKey = note.ContactNoteId; foreach (var accessor in ContactNoteFieldAccessors) @@ -947,7 +1845,14 @@ public async Task PrepareContactNoteWriteAsync(int departm slots.Add(new WriteSlot { FieldId = accessor.Key, RowKey = rowKey, WireValue = value, Apply = envelope => set(note, envelope) }); } - return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + + // A restore or neutralization mutates the entity without producing a broker slot, + // so EncryptSlotsAsync has nothing to report. The caller re-persists only on Changed. + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; } public async Task PrepareUnitStateWriteAsync(int departmentId, UnitState state, @@ -956,6 +1861,11 @@ public async Task PrepareUnitStateWriteAsync(int departmen if (state == null) return ProtectedWriteResult.Allowed(); + // Sentinel policy first: a value the editor never had revealed must not survive into the + // row, and this net runs AFTER the entity was saved, so skipping it would leave the + // literal placeholder stored. + var sentinelsHandled = ApplySentinelPolicy(state, null, UnitStateFieldAccessors); + var slots = new List(); var rowKey = state.UnitStateId.ToString(CultureInfo.InvariantCulture); foreach (var accessor in UnitStateFieldAccessors) @@ -988,8 +1898,15 @@ public async Task PrepareUnitStateWriteAsync(int departmen Apply = envelope => { state.ProtectedLongitudeEnvelope = envelope; state.Longitude = null; } }); - return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, () => state.IsProtected = true, cancellationToken); + + // A restore or neutralization mutates the entity without producing a broker slot, + // so EncryptSlotsAsync has nothing to report. The caller re-persists only on Changed. + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; } public async Task PrepareUdfFieldValueWriteAsync(int departmentId, UdfFieldValue value, @@ -998,6 +1915,12 @@ public async Task PrepareUdfFieldValueWriteAsync(int depar if (value == null) return ProtectedWriteResult.Allowed(); + // Sentinel policy first: a value the editor never had revealed must not survive into the + // row, and this net runs AFTER the entity was saved, so skipping it would leave the + // literal placeholder stored. + var sentinelsHandled = ApplySentinelPolicy(value, null, UdfFieldValueAccessors); + + var slots = new List(); foreach (var accessor in UdfFieldValueAccessors) { @@ -1016,7 +1939,14 @@ public async Task PrepareUdfFieldValueWriteAsync(int depar }); } - return await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + var result = await EncryptSlotsAsync(departmentId, grantToken, userId, workloadCaller, slots, null, cancellationToken); + + // A restore or neutralization mutates the entity without producing a broker slot, + // so EncryptSlotsAsync has nothing to report. The caller re-persists only on Changed. + if (sentinelsHandled && result.Success && !result.Changed) + result.Changed = true; + + return result; } /// @@ -1261,6 +2191,132 @@ private static void CollectUdfFieldValueSlots(ProtectedReadResult owner, UdfFiel } } + /// + /// Collects every enveloped text column of one row. The moderation family is six tables of + /// plain text columns with no companions, so they all share this instead of six near-identical + /// loops. + /// + private static void CollectTextSlots(ProtectedReadResult owner, List slots, + IReadOnlyDictionary Get, Action Set)> accessors, T entity, string rowKey) + where T : class + { + foreach (var accessor in accessors) + { + var value = accessor.Value.Get(entity); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = owner, + Reveal = plaintext => set(entity, plaintext), + Redact = () => set(entity, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + /// + /// An enveloped binary payload either resolves (the caller opted in) or is STRIPPED. It is + /// never handed back as ciphertext: these are files and export archives, and a serializer + /// downstream would happily write the bytes to a response. + /// + private static void CollectBinarySlot(ProtectedReadResult owner, List slots, string fieldId, + string rowKey, byte[] payload, bool include, Action apply) + { + if (!IsBinaryEnveloped(payload)) + return; + + if (!include) + { + // Metadata-only resolution: strip the ciphertext bytes so a serializer can never + // carry them out; the endpoints that serve the file opt into decryption. + apply(null); + owner.RedactedFields.Add(fieldId); + return; + } + + slots.Add(new Slot + { + FieldId = fieldId, + RowKey = rowKey, + WireValue = Convert.ToBase64String(payload), + IsBinary = true, + Owner = owner, + Reveal = plaintext => apply(Convert.FromBase64String(plaintext)), + Redact = () => apply(null) + }); + } + + private static void CollectMessageSlots(ProtectedReadResult owner, Message message, List slots) + { + var rowKey = message.MessageId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in MessageFieldAccessors) + { + var value = accessor.Value.Get(message); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = owner, + Reveal = plaintext => set(message, plaintext), + Redact = () => set(message, ProtectedDataEnvelope.RedactionValue) + }); + } + } + + private static void CollectMessageRecipientSlots(ProtectedReadResult owner, MessageRecipient recipient, + List slots) + { + var rowKey = recipient.MessageRecipientId.ToString(CultureInfo.InvariantCulture); + foreach (var accessor in MessageRecipientFieldAccessors) + { + var value = accessor.Value.Get(recipient); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(value)) + continue; + + var set = accessor.Value.Set; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = value, + Owner = owner, + Reveal = plaintext => set(recipient, plaintext), + Redact = () => set(recipient, ProtectedDataEnvelope.RedactionValue) + }); + } + + foreach (var accessor in MessageRecipientCompanionAccessors) + { + var envelope = accessor.Value.GetEnvelope(recipient); + if (!ProtectedDataEnvelope.HasEnvelopePrefix(envelope)) + continue; + + var setTyped = accessor.Value.SetTyped; + slots.Add(new Slot + { + FieldId = accessor.Key, + RowKey = rowKey, + WireValue = envelope, + Owner = owner, + // Companion reveal: the envelope held the invariant string of the typed value; + // an unparseable payload stays concealed (typed column remains null). + Reveal = plaintext => setTyped(recipient, + decimal.TryParse(plaintext, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed) ? parsed : null), + Redact = () => setTyped(recipient, null) + }); + } + } + private static void CollectNoteSlots(ProtectedReadResult owner, CallNote note, List slots) { var rowKey = note.CallNoteId.ToString(CultureInfo.InvariantCulture); diff --git a/Core/Resgrid.Services/TextResponsePromptService.cs b/Core/Resgrid.Services/TextResponsePromptService.cs index 35c8a34a6..fbe62c5a6 100644 --- a/Core/Resgrid.Services/TextResponsePromptService.cs +++ b/Core/Resgrid.Services/TextResponsePromptService.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Resgrid.Localization.Areas.User.SystemMessages; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Messages; @@ -37,7 +38,7 @@ public async Task RecordCalendarRsvpPromptAsync(CalendarItem calendarItem, strin && candidate.MessageRecipients?.Any(recipient => !recipient.IsDeleted && string.Equals(recipient.UserId, userId, StringComparison.Ordinal) - && TextResponsePromptMetadata.TryGetCalendarItemId(recipient.Note, out var calendarItemId) + && TextResponsePromptMetadata.TryGetCalendarItemId(recipient.PromptMetadata, out var calendarItemId) && calendarItemId == calendarItem.CalendarItemId) == true); if (message == null) @@ -49,14 +50,25 @@ public async Task RecordCalendarRsvpPromptAsync(CalendarItem calendarItem, strin new MessageRecipient { UserId = userId, - Note = metadata + PromptMetadata = metadata } } }; } - message.Subject = ("Calendar RSVP: " + calendarItem.Title).Truncate(150); - message.Body = ($"Event #{calendarItem.CalendarItemId}: {calendarItem.Title}. Reply YES or NO.").Truncate(4000); + // The prompt belongs to the department that owns the calendar item (M0137); it is created + // here rather than through SendMessageAsync, so the owner is stamped here too. + message.DepartmentId = calendarItem.DepartmentId; + // The title is cataloged (v9) and this runs without a grant, so an enveloped title becomes + // a generic phrase rather than ciphertext quoted into a message the write net would then + // encrypt as-is. + var promptTitle = ProtectedDataEnvelope.HasEnvelopePrefix(calendarItem.Title) + || calendarItem.Title == ProtectedDataEnvelope.RedactionValue + ? SystemMessagesResources.Get("AdpProtectedCalendarTitle", null) + : calendarItem.Title; + + message.Subject = ("Calendar RSVP: " + promptTitle).Truncate(150); + message.Body = ($"Event #{calendarItem.CalendarItemId}: {promptTitle}. Reply YES or NO.").Truncate(4000); message.SendingUserId = calendarItem.CreatorUserId; message.SentOn = now; message.ExpireOn = now.AddDays(1); diff --git a/Core/Resgrid.Services/UdfRenderingService.cs b/Core/Resgrid.Services/UdfRenderingService.cs index 0430afc40..4e54cbbbb 100644 --- a/Core/Resgrid.Services/UdfRenderingService.cs +++ b/Core/Resgrid.Services/UdfRenderingService.cs @@ -183,8 +183,17 @@ private static string RenderFormField(UdfField field, string currentValue) var requiredMark = field.IsRequired ? " *" : ""; var sb = new StringBuilder(); + + // A field showing the placeholder is marked for the reveal module (plan 7.2). The marker + // goes on the wrapper rather than each input variant, so every data type is covered by + // one attribute; the module finds the control inside it. Only redacted fields carry it, + // so an unprotected department's markup is unchanged. + var revealAttr = currentValue == ProtectedDataEnvelope.RedactionValue + ? $" data-adp-field=\"udffieldvalues.value:{field.UdfFieldId}\"" + : string.Empty; + // Bootstrap 3 form-horizontal: form-group with col-sm-2 label / col-sm-10 input - sb.AppendLine($"
    "); + sb.AppendLine($"
    "); if (dataType == UdfFieldDataType.Boolean) { diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs index d7101f92e..e114bde10 100644 --- a/Core/Resgrid.Services/UnitsService.cs +++ b/Core/Resgrid.Services/UnitsService.cs @@ -109,7 +109,22 @@ private void SendUnitVisibilityRefresh(int departmentId) public async Task SaveUnitLogAsync(UnitLog unitLog, CancellationToken cancellationToken = default(CancellationToken)) { - return await _unitLogsRepository.SaveOrUpdateAsync(unitLog, cancellationToken); + var saved = await _unitLogsRepository.SaveOrUpdateAsync(unitLog, cancellationToken); + + // ADP write safety net (plan 4.2/19.2, catalog v9). Runs AFTER the save because the AAD + // row key is the identity pk, then re-persists the enveloped row. Fails closed by + // throwing rather than leaving the value in plaintext. + var departmentId = saved.Unit?.DepartmentId + ?? (await _unitsRepository.GetByIdAsync(saved.UnitId))?.DepartmentId ?? 0; + + var protectedWrite = await _protectedWriteService.Value.PrepareUnitLogWriteAsync(departmentId, saved, + null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); unit log {saved.UnitLogId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _unitLogsRepository.SaveOrUpdateAsync(saved, cancellationToken); + + return saved; } public async Task> GetUnitsForDepartmentAsync(int departmentId) diff --git a/Core/Resgrid.Services/UserStateService.cs b/Core/Resgrid.Services/UserStateService.cs index af57a0172..5ebc74380 100644 --- a/Core/Resgrid.Services/UserStateService.cs +++ b/Core/Resgrid.Services/UserStateService.cs @@ -18,13 +18,16 @@ public class UserStateService : IUserStateService private static TimeSpan CacheLength = TimeSpan.FromDays(14); private readonly IUserStatesRepository _userStateRepository; + private readonly Lazy _protectedWriteService; private readonly IDepartmentsService _departmentsService; private readonly IEventAggregator _eventAggregator; private readonly ICacheProvider _cacheProvider; public UserStateService(IUserStatesRepository userStateRepository, IDepartmentsService departmentsService, - IEventAggregator eventAggregator, ICacheProvider cacheProvider) + IEventAggregator eventAggregator, ICacheProvider cacheProvider, + Lazy protectedWriteService) { + _protectedWriteService = protectedWriteService; _userStateRepository = userStateRepository; _departmentsService = departmentsService; _eventAggregator = eventAggregator; @@ -77,6 +80,16 @@ public async Task GetPreviousUserStateAsync(string userId, int userSt var saved = await _userStateRepository.SaveOrUpdateAsync(us, cancellationToken); + // ADP write safety net (plan 4.2/19.2, catalog v9). Runs AFTER the save because the AAD + // row key is the identity pk, then re-persists the enveloped row. A state note is often + // why someone is unavailable, which is exactly the kind of free text the plan protects. + var protectedWrite = await _protectedWriteService.Value.PrepareUserStateWriteAsync(departmentId, saved, + null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); user state {saved.UserStateId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _userStateRepository.SaveOrUpdateAsync(saved, cancellationToken); + var previousStaffing = await _userStateRepository.GetPreviousUserStateByUserIdAsync(userId, saved.UserStateId); _eventAggregator.SendMessage(new UserStaffingEvent() { DepartmentId = departmentId, Staffing = saved, PreviousStaffing = previousStaffing }); InvalidateLatestStatesForDepartmentCache(departmentId); @@ -95,6 +108,16 @@ public async Task GetPreviousUserStateAsync(string userId, int userSt var saved = await _userStateRepository.SaveOrUpdateAsync(us, cancellationToken); + // ADP write safety net (plan 4.2/19.2, catalog v9). Runs AFTER the save because the AAD + // row key is the identity pk, then re-persists the enveloped row. A state note is often + // why someone is unavailable, which is exactly the kind of free text the plan protects. + var protectedWrite = await _protectedWriteService.Value.PrepareUserStateWriteAsync(departmentId, saved, + null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); user state {saved.UserStateId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _userStateRepository.SaveOrUpdateAsync(saved, cancellationToken); + var previousStaffing = await _userStateRepository.GetPreviousUserStateByUserIdAsync(userId, saved.UserStateId); _eventAggregator.SendMessage(new UserStaffingEvent() { DepartmentId = departmentId, Staffing = saved, PreviousStaffing = previousStaffing, AutoGenerated = autoGenerated }); InvalidateLatestStatesForDepartmentCache(departmentId); @@ -113,6 +136,16 @@ public async Task GetPreviousUserStateAsync(string userId, int userSt var saved = await _userStateRepository.SaveOrUpdateAsync(us, cancellationToken); + // ADP write safety net (plan 4.2/19.2, catalog v9). Runs AFTER the save because the AAD + // row key is the identity pk, then re-persists the enveloped row. A state note is often + // why someone is unavailable, which is exactly the kind of free text the plan protects. + var protectedWrite = await _protectedWriteService.Value.PrepareUserStateWriteAsync(departmentId, saved, + null, null, workloadCaller: true, cancellationToken); + if (!protectedWrite.Success) + throw new InvalidOperationException($"Protected write blocked ({protectedWrite.Reason}); user state {saved.UserStateId} has transient plaintext pending re-encryption."); + if (protectedWrite.Changed) + saved = await _userStateRepository.SaveOrUpdateAsync(saved, cancellationToken); + var previousStaffing = await _userStateRepository.GetPreviousUserStateByUserIdAsync(userId, saved.UserStateId); _eventAggregator.SendMessage(new UserStaffingEvent() { DepartmentId = departmentId, Staffing = saved, PreviousStaffing = previousStaffing }); InvalidateLatestStatesForDepartmentCache(departmentId); diff --git a/Providers/Resgrid.Providers.Chatbot/ChatbotProviderModule.cs b/Providers/Resgrid.Providers.Chatbot/ChatbotProviderModule.cs index 484980e22..96c39b4ae 100644 --- a/Providers/Resgrid.Providers.Chatbot/ChatbotProviderModule.cs +++ b/Providers/Resgrid.Providers.Chatbot/ChatbotProviderModule.cs @@ -47,6 +47,11 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType() .As() .InstancePerLifetimeScope(); + + // ADP outbound net (plan 7.5.1): a chat message lands in a third-party service's storage + // and, on a shared channel, in front of people who are not the intended recipient. + builder.RegisterDecorator(); } } } diff --git a/Providers/Resgrid.Providers.Chatbot/Services/ProtectedChatbotOutboundDecorator.cs b/Providers/Resgrid.Providers.Chatbot/Services/ProtectedChatbotOutboundDecorator.cs new file mode 100644 index 000000000..854be95fd --- /dev/null +++ b/Providers/Resgrid.Providers.Chatbot/Services/ProtectedChatbotOutboundDecorator.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Providers.Chatbot.Services +{ + /// + /// Outbound-boundary net for chat platforms (ADP plan section 7.5.1). Discord, Slack and + /// Telegram messages leave through , so wrapping the one + /// interface covers every platform adapter behind it. + /// + /// This channel is worth covering even though the dispatch path already projects: a chat + /// message lands in a third-party service's storage and, on a shared channel, in front of + /// people who are not the intended recipient. Like the other carrier decorators it scrubs and + /// logs rather than refusing — a degraded message still tells someone to open the app. + /// + public class ProtectedChatbotOutboundDecorator : IChatbotOutboundService + { + private readonly IChatbotOutboundService _inner; + + public ProtectedChatbotOutboundDecorator(IChatbotOutboundService inner) + { + _inner = inner; + } + + public async Task SendToUserAsync(string userId, int departmentId, + ChatbotOutboundMessage message) + { + try + { + if (message != null) + { + var scrubbed = 0; + + message.Title = ProtectedOutboundGuard.Scrub(message.Title, out var titleCount); + scrubbed += titleCount; + + message.Body = ProtectedOutboundGuard.Scrub(message.Body, out var bodyCount); + scrubbed += bodyCount; + + if (scrubbed > 0) + Logging.LogError($"ADP outbound net scrubbed {scrubbed} enveloped value(s) from a chat-platform message " + + $"for department {departmentId}. A notification path is missing its protected projection."); + } + } + catch (Exception ex) + { + Logging.LogException(ex, $"ProtectedChatbotOutboundDecorator failed while sanitizing a chat message for department {departmentId}"); + } + + return await _inner.SendToUserAsync(userId, departmentId, message); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0126_SeedAdpFeatureFlagAndAddon.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0126_SeedAdpFeatureFlagAndAddon.cs index 925aa6eb3..d180a3946 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0126_SeedAdpFeatureFlagAndAddon.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0126_SeedAdpFeatureFlagAndAddon.cs @@ -9,7 +9,8 @@ namespace Resgrid.Providers.Migrations.Migrations /// 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). + /// ExternalId carries the Stripe yearly price id (under product prod_V9NRrdSq5hxCk8); M0136 +/// repoints existing rows when that price id changes. /// 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 @@ -39,7 +40,7 @@ public override void Up() 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', '');"); + "VALUES ('" + AdpPlanAddonId + "', 2, 999, 'price_0U9wyZqJFDZJcnkVfFDTHoHl', '');"); } public override void Down() diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0136_UpdateAdpAddonStripePrice.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0136_UpdateAdpAddonStripePrice.cs new file mode 100644 index 000000000..57587846c --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0136_UpdateAdpAddonStripePrice.cs @@ -0,0 +1,38 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Repoints the ADP PlanAddon row at the current Stripe yearly price id. M0126 seeded the + /// original id; that price was replaced before launch, and any environment that already ran + /// M0126 holds the stale value, so the seed edit alone would only fix fresh installs. + /// + /// The UPDATE is deliberately guarded on the OLD value: an operator who has already pointed a + /// data center at a different price (a regional entity, a negotiated price) must not have that + /// choice silently overwritten by a migration. Anything other than the seeded id is left alone. + /// + [Migration(136)] + public class M0136_UpdateAdpAddonStripePrice : Migration + { + // Same fixed id M0126 seeds. + private const string AdpPlanAddonId = "b3a4f9d2-6c1e-4f8a-9d27-5e9c1b7a4a02"; + + private const string OldPriceId = "price_0U94gcqJFDZJcnkVOJNe9SnR"; + private const string NewPriceId = "price_0U9wyZqJFDZJcnkVfFDTHoHl"; + + public override void Up() + { + Execute.Sql( + "UPDATE [PlanAddons] SET [ExternalId] = '" + NewPriceId + "' " + + "WHERE [PlanAddonId] = '" + AdpPlanAddonId + "' AND [ExternalId] = '" + OldPriceId + "';"); + } + + public override void Down() + { + // Symmetric and equally guarded: only a row still carrying the new id goes back. + Execute.Sql( + "UPDATE [PlanAddons] SET [ExternalId] = '" + OldPriceId + "' " + + "WHERE [PlanAddonId] = '" + AdpPlanAddonId + "' AND [ExternalId] = '" + NewPriceId + "';"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0137_AddMessageDepartmentOwnership.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0137_AddMessageDepartmentOwnership.cs new file mode 100644 index 000000000..00dac3235 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0137_AddMessageDepartmentOwnership.cs @@ -0,0 +1,190 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Gives Messages and MessageRecipients a DepartmentId of their own (ADP plan section 5.1: + /// "add DepartmentId to child tables that lack it, deriving ownership from a verified parent + /// before migration"). + /// + /// Neither table could be bound to the protected-field catalog without this. The envelope AAD + /// is rgdp|{departmentId}|{fieldId}|{rowKey}|{version}, so a row that cannot be attributed to + /// exactly ONE department can neither be encrypted (which key?) nor decrypted afterwards (the + /// AAD could not be rebuilt). Deriving the department through a join at read time is not a + /// substitute: the only route is the sender's or recipient's membership, and membership MOVES — + /// a user changing departments would silently change the AAD and orphan every envelope already + /// written. The value has to be resolved once and frozen on the row. Both tables get the column + /// because a child that derives its department through a join has no value of its own to bind. + /// + /// Backfill, in order, and never overwriting a value that is already there: + /// 1. The sender's active department, where the sender has exactly one. "Active" is the same + /// rule the application uses everywhere else: DepartmentMembers, not deleted, IsActive or + /// IsDefault (SelectDepartmentByUserIdQuery). + /// 2. Where that is silent (a sender with several active memberships, a deleted account, a + /// system-generated message), the recipients' majority. A tie resolves nothing and is left + /// alone. When the sender does have memberships, the consensus is restricted to those, so + /// recipients can only DISAMBIGUATE the sender, never relocate the message. + /// 3. A narrow sanity check on step 1: if not one recipient belongs to the department the + /// sender's flags pointed at, and the recipients agree on a different department the + /// sender ALSO belongs to, the recipients win. That is the shape of a sender whose active + /// flag has since moved to their newer department. + /// 4. Recipients inherit their parent message's answer. + /// + /// Rows that survive all four passes stay NULL. That is deliberate and safe: unresolved + /// ownership means the row is excluded from encryption rather than encrypted under a guess. + /// + /// Nullable for the same reason, and TransactionBehavior.None so the batched updates commit as + /// they go — Messages and MessageRecipients are among the largest tables in the schema, and one + /// transaction spanning the whole backfill would hold locks for its entire duration. Every step + /// is guarded on IS NULL, so an interrupted run is simply re-run. + /// + [Migration(137, TransactionBehavior.None)] + public class M0137_AddMessageDepartmentOwnership : Migration + { + private const int BatchSize = 5000; + + public override void Up() + { + if (!Schema.Table("Messages").Column("DepartmentId").Exists()) + Alter.Table("Messages").AddColumn("DepartmentId").AsInt32().Nullable(); + + if (!Schema.Table("MessageRecipients").Column("DepartmentId").Exists()) + Alter.Table("MessageRecipients").AddColumn("DepartmentId").AsInt32().Nullable(); + + Execute.Sql($@" +-- One row per user with their active membership(s). DepartmentCount = 1 is the unambiguous case. +IF OBJECT_ID('tempdb..#AdpActiveMembership') IS NOT NULL DROP TABLE #AdpActiveMembership; +SELECT DISTINCT dm.[UserId], dm.[DepartmentId] +INTO #AdpActiveMembership +FROM [DepartmentMembers] dm +WHERE dm.[IsDeleted] = 0 AND (dm.[IsActive] = 1 OR dm.[IsDefault] = 1); + +CREATE CLUSTERED INDEX [IX_AdpActiveMembership] ON #AdpActiveMembership ([UserId], [DepartmentId]); + +IF OBJECT_ID('tempdb..#AdpActiveDepartment') IS NOT NULL DROP TABLE #AdpActiveDepartment; +SELECT am.[UserId], MIN(am.[DepartmentId]) AS [DepartmentId], COUNT(*) AS [DepartmentCount] +INTO #AdpActiveDepartment +FROM #AdpActiveMembership am +GROUP BY am.[UserId]; + +CREATE UNIQUE CLUSTERED INDEX [IX_AdpActiveDepartment] ON #AdpActiveDepartment ([UserId]); + +-- Pass 1: the sender's active department, only where it is unambiguous. +WHILE 1 = 1 +BEGIN + UPDATE TOP ({BatchSize}) m + SET m.[DepartmentId] = a.[DepartmentId] + FROM [Messages] m + INNER JOIN #AdpActiveDepartment a ON a.[UserId] = m.[SendingUserId] + WHERE m.[DepartmentId] IS NULL AND a.[DepartmentCount] = 1; + + IF @@ROWCOUNT = 0 BREAK; +END + +-- One vote per recipient membership, then the strict winner per message (TiedCount = 1). +IF OBJECT_ID('tempdb..#AdpRecipientVotes') IS NOT NULL DROP TABLE #AdpRecipientVotes; +SELECT mr.[MessageId], am.[DepartmentId], COUNT_BIG(*) AS [Votes] +INTO #AdpRecipientVotes +FROM [MessageRecipients] mr +INNER JOIN #AdpActiveMembership am ON am.[UserId] = mr.[UserId] +GROUP BY mr.[MessageId], am.[DepartmentId]; + +CREATE CLUSTERED INDEX [IX_AdpRecipientVotes] ON #AdpRecipientVotes ([MessageId], [DepartmentId]); + +IF OBJECT_ID('tempdb..#AdpRecipientConsensus') IS NOT NULL DROP TABLE #AdpRecipientConsensus; +SELECT v.[MessageId], MIN(v.[DepartmentId]) AS [DepartmentId], COUNT(*) AS [TiedCount] +INTO #AdpRecipientConsensus +FROM #AdpRecipientVotes v +WHERE v.[Votes] = (SELECT MAX(v2.[Votes]) FROM #AdpRecipientVotes v2 WHERE v2.[MessageId] = v.[MessageId]) +GROUP BY v.[MessageId]; + +CREATE UNIQUE CLUSTERED INDEX [IX_AdpRecipientConsensus] ON #AdpRecipientConsensus ([MessageId]); + +-- Pass 2: recipient majority for what pass 1 could not answer. Restricted to the sender's own +-- memberships when they have any, so this disambiguates the sender and never relocates a message. +WHILE 1 = 1 +BEGIN + UPDATE TOP ({BatchSize}) m + SET m.[DepartmentId] = c.[DepartmentId] + FROM [Messages] m + INNER JOIN #AdpRecipientConsensus c ON c.[MessageId] = m.[MessageId] AND c.[TiedCount] = 1 + WHERE m.[DepartmentId] IS NULL + AND (NOT EXISTS (SELECT 1 FROM #AdpActiveMembership s WHERE s.[UserId] = m.[SendingUserId]) + OR EXISTS (SELECT 1 FROM #AdpActiveMembership s + WHERE s.[UserId] = m.[SendingUserId] AND s.[DepartmentId] = c.[DepartmentId])); + + IF @@ROWCOUNT = 0 BREAK; +END + +-- Pass 3: sanity check on pass 1. Only fires when NO recipient belongs to the department the +-- sender's flags produced and the recipients agree on another department the sender is in too. +-- The loop terminates because each updated row stops matching m.[DepartmentId] <> c.[DepartmentId]. +WHILE 1 = 1 +BEGIN + UPDATE TOP ({BatchSize}) m + SET m.[DepartmentId] = c.[DepartmentId] + FROM [Messages] m + INNER JOIN #AdpRecipientConsensus c ON c.[MessageId] = m.[MessageId] AND c.[TiedCount] = 1 + INNER JOIN #AdpActiveMembership s ON s.[UserId] = m.[SendingUserId] AND s.[DepartmentId] = c.[DepartmentId] + WHERE m.[DepartmentId] IS NOT NULL + AND m.[DepartmentId] <> c.[DepartmentId] + AND NOT EXISTS (SELECT 1 FROM #AdpRecipientVotes rv + WHERE rv.[MessageId] = m.[MessageId] AND rv.[DepartmentId] = m.[DepartmentId]); + + IF @@ROWCOUNT = 0 BREAK; +END + +-- Pass 4: recipients inherit the parent message's answer. +WHILE 1 = 1 +BEGIN + UPDATE TOP ({BatchSize}) mr + SET mr.[DepartmentId] = m.[DepartmentId] + FROM [MessageRecipients] mr + INNER JOIN [Messages] m ON m.[MessageId] = mr.[MessageId] + WHERE mr.[DepartmentId] IS NULL AND m.[DepartmentId] IS NOT NULL; + + IF @@ROWCOUNT = 0 BREAK; +END + +DROP TABLE #AdpRecipientConsensus; +DROP TABLE #AdpRecipientVotes; +DROP TABLE #AdpActiveDepartment; +DROP TABLE #AdpActiveMembership;"); + + // The migration engine batches by department and orders by primary key. + if (!Schema.Table("Messages").Index("IX_Messages_DepartmentId").Exists()) + Create.Index("IX_Messages_DepartmentId") + .OnTable("Messages") + .OnColumn("DepartmentId").Ascending() + .OnColumn("MessageId").Ascending(); + + if (!Schema.Table("MessageRecipients").Index("IX_MessageRecipients_DepartmentId").Exists()) + Create.Index("IX_MessageRecipients_DepartmentId") + .OnTable("MessageRecipients") + .OnColumn("DepartmentId").Ascending() + .OnColumn("MessageRecipientId").Ascending(); + } + + public override void Down() + { + // Safe to reverse: the column is additive and, until these tables enter the catalog, + // nothing has been encrypted under the ownership it records. Refuse anyway if a + // protected row exists — at that point the column is load-bearing for decryption. + Execute.Sql(@" +IF EXISTS (SELECT 1 FROM [MessageRecipients] WHERE [IsProtected] = 1) + THROW 51000, 'M0137 rollback refused: protected MessageRecipients rows exist and their envelopes are bound to DepartmentId.', 1;"); + + if (Schema.Table("MessageRecipients").Index("IX_MessageRecipients_DepartmentId").Exists()) + Delete.Index("IX_MessageRecipients_DepartmentId").OnTable("MessageRecipients"); + + if (Schema.Table("Messages").Index("IX_Messages_DepartmentId").Exists()) + Delete.Index("IX_Messages_DepartmentId").OnTable("Messages"); + + if (Schema.Table("MessageRecipients").Column("DepartmentId").Exists()) + Delete.Column("DepartmentId").FromTable("MessageRecipients"); + + if (Schema.Table("Messages").Column("DepartmentId").Exists()) + Delete.Column("DepartmentId").FromTable("Messages"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0138_AddMessageRecipientPromptMetadata.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0138_AddMessageRecipientPromptMetadata.cs new file mode 100644 index 000000000..2d59f6091 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0138_AddMessageRecipientPromptMetadata.cs @@ -0,0 +1,55 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Separates machine metadata from the member's own words on MessageRecipients. + /// + /// Note was doing two jobs: it held whatever a member typed when answering a message, AND the + /// TextResponsePromptMetadata token that says which calendar item or poll a prompt belongs to + /// ("calendar-rsvp:42", "poll:7"). That token is parsed by the chatbot inbound resolver, the + /// RSVP prompt service and both message controllers — paths that hold no Protected Data Grant, + /// against a broker whose workload lane is encrypt-only. So while the two shared a column, Note + /// could not enter the protected-field catalog: encrypting it would have silently broken + /// calendar RSVP and poll replies for the departments that turned protection on. + /// + /// With the token in its own column, Note becomes ordinary member free text and joins the + /// protected-field catalog with the rest of the message family (v7). PromptMetadata stays + /// plaintext deliberately — it is a row pointer, holds nothing about a person, and every reader + /// of it runs without a grant. + /// + /// The backfill MOVES the token rather than copying it: leaving a duplicate behind would put a + /// machine token inside the column that is about to be encrypted, for no reader. + /// + [Migration(138)] + public class M0138_AddMessageRecipientPromptMetadata : Migration + { + public override void Up() + { + if (!Schema.Table("MessageRecipients").Column("PromptMetadata").Exists()) + Alter.Table("MessageRecipients").AddColumn("PromptMetadata").AsString(int.MaxValue).Nullable(); + + // Only rows whose Note IS a token — the prefixes are the ones TextResponsePromptMetadata + // writes. A member's typed note is left exactly where it is. + Execute.Sql(@" +UPDATE [MessageRecipients] +SET [PromptMetadata] = [Note], [Note] = NULL +WHERE [PromptMetadata] IS NULL + AND [Note] IS NOT NULL + AND ([Note] LIKE 'calendar-rsvp:%' OR [Note] LIKE 'poll:%');"); + } + + public override void Down() + { + // Put the tokens back before the column disappears, or the prompts they point at become + // unanswerable. Only where Note is free — a member's note must never be overwritten. + Execute.Sql(@" +UPDATE [MessageRecipients] +SET [Note] = [PromptMetadata], [PromptMetadata] = NULL +WHERE [PromptMetadata] IS NOT NULL AND ([Note] IS NULL OR LTRIM(RTRIM([Note])) = '');"); + + if (Schema.Table("MessageRecipients").Column("PromptMetadata").Exists()) + Delete.Column("PromptMetadata").FromTable("MessageRecipients"); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0139_AddModerationProtectionMarkers.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0139_AddModerationProtectionMarkers.cs new file mode 100644 index 000000000..1afef7c2c --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0139_AddModerationProtectionMarkers.cs @@ -0,0 +1,52 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Row markers for the moderation family entering the protected-field catalog (ADP plan + /// section 5.3). IsProtected says "this row's cataloged values carry rgdp envelopes" — the same + /// marker Calls' children, unit states, member data and message recipients already carry. + /// + /// Only the marker is needed: every text column in these tables was already NVARCHAR(MAX) when + /// the tables were created (M0107, M0112), M0127 widened the one exception + /// (ModerationRequests.OriginalContentType), and both binary payloads — OriginalContent and + /// EvidenceContent — are VARBINARY(MAX), which holds an rgdpb envelope as-is. + /// + /// Additive and inert until the department is enrolled and the catalog-upgrade sweep runs. + /// + [Migration(139)] + public class M0139_AddModerationProtectionMarkers : Migration + { + private static readonly string[] Tables = + { + "ModerationRequests", + "ModerationReports", + "ModerationActions", + "ChatMessageFlags", + "ChatModerationActions", + "ChatExports" + }; + + public override void Up() + { + foreach (var table in Tables) + { + if (!Schema.Table(table).Column("IsProtected").Exists()) + Alter.Table(table) + .AddColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false); + } + } + + public override void Down() + { + // The marker only. The values themselves stay exactly as they are: for an enrolled + // department these columns hold the ONLY copy of the moderated content, and dropping or + // narrowing them would destroy evidence that exists nowhere else. + foreach (var table in Tables) + { + if (Schema.Table(table).Column("IsProtected").Exists()) + Delete.Column("IsProtected").FromTable(table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0140_AddRemainingProtectionMarkers.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0140_AddRemainingProtectionMarkers.cs new file mode 100644 index 000000000..6dcf0525c --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0140_AddRemainingProtectionMarkers.cs @@ -0,0 +1,49 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Row markers for the last of the plan's protected-field candidates (sections 5.2 and 22.1): + /// unit logs, user state notes, calendar items, documents, and the dormant mailbox credentials + /// on distribution lists. IsProtected says "this row's cataloged values carry rgdp envelopes". + /// + /// Only the marker is needed. M0127 widened UnitLogs.Narrative, UserStates.Note and the + /// DistributionLists credential columns; Documents and CalendarItems were already NVARCHAR(MAX) + /// where it matters, and Documents.Data is VARBINARY(MAX), which holds an rgdpb envelope as-is. + /// + /// Additive and inert until the department is enrolled and the catalog-upgrade sweep runs. + /// + [Migration(140)] + public class M0140_AddRemainingProtectionMarkers : Migration + { + private static readonly string[] Tables = + { + "UnitLogs", + "UserStates", + "CalendarItems", + "Documents", + "DistributionLists" + }; + + public override void Up() + { + foreach (var table in Tables) + { + if (!Schema.Table(table).Column("IsProtected").Exists()) + Alter.Table(table) + .AddColumn("IsProtected").AsBoolean().NotNullable().WithDefaultValue(false); + } + } + + public override void Down() + { + // The marker only: for an enrolled department the columns beside it hold the only copy + // of the values, and dropping or narrowing them would destroy data. + foreach (var table in Tables) + { + if (Schema.Table(table).Column("IsProtected").Exists()) + Delete.Column("IsProtected").FromTable(table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0141_ContractLegacyMemberProfileData.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0141_ContractLegacyMemberProfileData.cs new file mode 100644 index 000000000..3effc6a9f --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0141_ContractLegacyMemberProfileData.cs @@ -0,0 +1,81 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// CONTRACT phase of the expand/relocate/contract move of member profile data (ADP plan + /// section 5.1). M0132/M0133 expanded (department-scoped rows beside the global profile), + /// M0134 relocated every member, and the application has read and written the department copy + /// ever since. This removes the originals. + /// + /// A UserProfile is GLOBAL to a person across every department they belong to, so it can never + /// be encrypted under one department's key — that is the whole reason the data moved. Leaving + /// the plaintext originals behind would mean an enrolled department encrypts a member's + /// identification number and address while an identical plaintext copy sits one table over. + /// + /// Three things happen, in this order and only in this order: + /// 1. REFUSE if any member still has legacy data that relocation has not stamped as moved. + /// A contract that runs early destroys the only copy, so this is a hard stop rather than + /// a comment. + /// 2. Delete the Address rows that ONLY a user profile referenced. A member's home address is + /// now held department-scoped (and encrypted for an enrolled department); an orphaned + /// plaintext row in Addresses would defeat that. Rows any contact, department, station or + /// department profile still points at are left completely alone. + /// 3. Clear the profile's address links and drop IdentificationNumber. + /// + /// The address links are CLEARED rather than dropped: they are ordinary nullable columns that + /// several code paths still read defensively, and a null reads the same as an absent column. + /// + [Migration(141)] + public class M0141_ContractLegacyMemberProfileData : Migration + { + public override void Up() + { + Execute.Sql(@" +IF EXISTS ( + SELECT 1 + FROM [UserProfiles] up + INNER JOIN [DepartmentMembers] dm ON dm.[UserId] = up.[UserId] AND dm.[IsDeleted] = 0 + LEFT JOIN [DepartmentMemberSensitiveData] s + ON s.[DepartmentId] = dm.[DepartmentId] AND s.[UserId] = up.[UserId] + WHERE ( + (up.[IdentificationNumber] IS NOT NULL AND LTRIM(RTRIM(up.[IdentificationNumber])) <> '') + OR up.[HomeAddressId] IS NOT NULL + OR up.[MailingAddressId] IS NOT NULL + ) + AND (s.[DepartmentMemberSensitiveDataId] IS NULL OR s.[LegacyProfileRelocatedOn] IS NULL)) + THROW 51000, 'M0141 refused: members still hold legacy profile data that relocation has not stamped as moved. Run the member profile relocation to completion first — this migration destroys the originals.', 1;"); + + // Only addresses nothing else references. A shared row (a contact's, a station's) is + // left exactly as it is. + Execute.Sql(@" +DELETE a +FROM [Addresses] a +WHERE EXISTS (SELECT 1 FROM [UserProfiles] up + WHERE up.[HomeAddressId] = a.[AddressId] OR up.[MailingAddressId] = a.[AddressId]) + AND NOT EXISTS (SELECT 1 FROM [Contacts] c + WHERE c.[PhysicalAddressId] = a.[AddressId] OR c.[MailingAddressId] = a.[AddressId]) + AND NOT EXISTS (SELECT 1 FROM [Departments] d WHERE d.[AddressId] = a.[AddressId]) + AND NOT EXISTS (SELECT 1 FROM [DepartmentGroups] g WHERE g.[AddressId] = a.[AddressId]) + AND NOT EXISTS (SELECT 1 FROM [DepartmentProfiles] p WHERE p.[AddressId] = a.[AddressId]);"); + + Execute.Sql(@" +UPDATE [UserProfiles] +SET [HomeAddressId] = NULL, [MailingAddressId] = NULL +WHERE [HomeAddressId] IS NOT NULL OR [MailingAddressId] IS NOT NULL;"); + + if (Schema.Table("UserProfiles").Column("IdentificationNumber").Exists()) + Delete.Column("IdentificationNumber").FromTable("UserProfiles"); + } + + public override void Down() + { + // The column comes back empty. Its values moved to DepartmentMemberSensitiveData long + // before this ran and are encrypted there for an enrolled department, so there is + // nothing to restore and nothing that could restore it — a rollback recreates the shape, + // not the data. The deleted Address rows do not come back either. + if (!Schema.Table("UserProfiles").Column("IdentificationNumber").Exists()) + Alter.Table("UserProfiles").AddColumn("IdentificationNumber").AsString(int.MaxValue).Nullable(); + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0142_AddPolicyLastBillingEventId.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0142_AddPolicyLastBillingEventId.cs new file mode 100644 index 000000000..99cde18b4 --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0142_AddPolicyLastBillingEventId.cs @@ -0,0 +1,29 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Records the last ADP addon billing event applied to a department (plan 17.2/17.3). + /// + /// Payment providers retry and duplicate webhooks, and they deliver out of order. Without a + /// record of what has already been applied, a replayed "cancelled" would re-schedule an + /// offboarding the member had since revoked. Storing the provider's event id makes the handler + /// idempotent against exactly that. + /// + [Migration(142)] + public class M0142_AddPolicyLastBillingEventId : Migration + { + public override void Up() + { + if (!Schema.Table("DepartmentDataProtectionPolicies").Column("LastBillingEventId").Exists()) + Alter.Table("DepartmentDataProtectionPolicies") + .AddColumn("LastBillingEventId").AsString(256).Nullable(); + } + + public override void Down() + { + if (Schema.Table("DepartmentDataProtectionPolicies").Column("LastBillingEventId").Exists()) + Delete.Column("LastBillingEventId").FromTable("DepartmentDataProtectionPolicies"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0126_SeedAdpFeatureFlagAndAddonPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0126_SeedAdpFeatureFlagAndAddonPg.cs index 5be900c7c..b588fb045 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0126_SeedAdpFeatureFlagAndAddonPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0126_SeedAdpFeatureFlagAndAddonPg.cs @@ -9,7 +9,8 @@ namespace Resgrid.Providers.MigrationsPg.Migrations /// 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). + /// ExternalId carries the Stripe yearly price id (under product prod_V9NRrdSq5hxCk8); M0136 +/// repoints existing rows when that price id changes. /// 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 @@ -39,7 +40,7 @@ public override void Up() Execute.Sql( "INSERT INTO planaddons (planaddonid, addontype, cost, externalid, testexternalid) " + - "SELECT '" + AdpPlanAddonId + "', 2, 999, 'price_0U94gcqJFDZJcnkVOJNe9SnR', '' " + + "SELECT '" + AdpPlanAddonId + "', 2, 999, 'price_0U9wyZqJFDZJcnkVfFDTHoHl', '' " + "WHERE NOT EXISTS (SELECT 1 FROM planaddons WHERE planaddonid = '" + AdpPlanAddonId + "');"); } diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0136_UpdateAdpAddonStripePricePg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0136_UpdateAdpAddonStripePricePg.cs new file mode 100644 index 000000000..d25beead1 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0136_UpdateAdpAddonStripePricePg.cs @@ -0,0 +1,38 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Repoints the ADP PlanAddon row at the current Stripe yearly price id. M0126 seeded the + /// original id; that price was replaced before launch, and any environment that already ran + /// M0126 holds the stale value, so the seed edit alone would only fix fresh installs. + /// + /// The UPDATE is deliberately guarded on the OLD value: an operator who has already pointed a + /// data center at a different price (a regional entity, a negotiated price) must not have that + /// choice silently overwritten by a migration. Anything other than the seeded id is left alone. + /// + [Migration(136)] + public class M0136_UpdateAdpAddonStripePricePg : Migration + { + // Same fixed id M0126 seeds. + private const string AdpPlanAddonId = "b3a4f9d2-6c1e-4f8a-9d27-5e9c1b7a4a02"; + + private const string OldPriceId = "price_0U94gcqJFDZJcnkVOJNe9SnR"; + private const string NewPriceId = "price_0U9wyZqJFDZJcnkVfFDTHoHl"; + + public override void Up() + { + Execute.Sql( + "UPDATE planaddons SET externalid = '" + NewPriceId + "' " + + "WHERE planaddonid = '" + AdpPlanAddonId + "' AND externalid = '" + OldPriceId + "';"); + } + + public override void Down() + { + // Symmetric and equally guarded: only a row still carrying the new id goes back. + Execute.Sql( + "UPDATE planaddons SET externalid = '" + OldPriceId + "' " + + "WHERE planaddonid = '" + AdpPlanAddonId + "' AND externalid = '" + NewPriceId + "';"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0137_AddMessageDepartmentOwnershipPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0137_AddMessageDepartmentOwnershipPg.cs new file mode 100644 index 000000000..1ea487a0f --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0137_AddMessageDepartmentOwnershipPg.cs @@ -0,0 +1,248 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Gives messages and messagerecipients a departmentid of their own (ADP plan section 5.1: + /// "add DepartmentId to child tables that lack it, deriving ownership from a verified parent + /// before migration"). + /// + /// Neither table could be bound to the protected-field catalog without this. The envelope AAD + /// is rgdp|{departmentId}|{fieldId}|{rowKey}|{version}, so a row that cannot be attributed to + /// exactly ONE department can neither be encrypted (which key?) nor decrypted afterwards (the + /// AAD could not be rebuilt). Deriving the department through a join at read time is not a + /// substitute: the only route is the sender's or recipient's membership, and membership MOVES — + /// a user changing departments would silently change the AAD and orphan every envelope already + /// written. The value has to be resolved once and frozen on the row. Both tables get the column + /// because a child that derives its department through a join has no value of its own to bind. + /// + /// Backfill, in order, and never overwriting a value that is already there: + /// 1. The sender's active department, where the sender has exactly one. "Active" is the same + /// rule the application uses everywhere else: departmentmembers, not deleted, isactive or + /// isdefault (SelectDepartmentByUserIdQuery). + /// 2. Where that is silent (a sender with several active memberships, a deleted account, a + /// system-generated message), the recipients' majority. A tie resolves nothing and is left + /// alone. When the sender does have memberships, the consensus is restricted to those, so + /// recipients can only DISAMBIGUATE the sender, never relocate the message. + /// 3. A narrow sanity check on step 1: if not one recipient belongs to the department the + /// sender's flags pointed at, and the recipients agree on a different department the + /// sender ALSO belongs to, the recipients win. That is the shape of a sender whose active + /// flag has since moved to their newer department. + /// 4. Recipients inherit their parent message's answer. + /// + /// Rows that survive all four passes stay NULL. That is deliberate and safe: unresolved + /// ownership means the row is excluded from encryption rather than encrypted under a guess. + /// + /// TransactionBehavior.None for two reasons: CREATE INDEX CONCURRENTLY cannot run inside a + /// transaction, and the batched updates commit as they go rather than holding locks across the + /// whole backfill of two of the largest tables in the schema. Every step is guarded on IS NULL, + /// so an interrupted run is simply re-run. + /// + [Migration(137, TransactionBehavior.None)] + public class M0137_AddMessageDepartmentOwnershipPg : Migration + { + private const int BatchSize = 5000; + + public override void Up() + { + if (!Schema.Table("messages").Column("departmentid").Exists()) + Alter.Table("messages").AddColumn("departmentid").AsInt32().Nullable(); + + if (!Schema.Table("messagerecipients").Column("departmentid").Exists()) + Alter.Table("messagerecipients").AddColumn("departmentid").AsInt32().Nullable(); + + // Unlogged temp tables scoped to this session; dropped explicitly at the end. + Execute.Sql(@" +DROP TABLE IF EXISTS pg_temp.adp_active_membership; +CREATE TEMP TABLE adp_active_membership AS +SELECT DISTINCT dm.userid, dm.departmentid +FROM departmentmembers dm +WHERE dm.isdeleted = false AND (dm.isactive = true OR dm.isdefault = true); + +CREATE INDEX ix_adp_active_membership ON adp_active_membership (userid, departmentid); + +DROP TABLE IF EXISTS pg_temp.adp_active_department; +CREATE TEMP TABLE adp_active_department AS +SELECT am.userid, MIN(am.departmentid) AS departmentid, COUNT(*) AS departmentcount +FROM adp_active_membership am +GROUP BY am.userid; + +CREATE UNIQUE INDEX ix_adp_active_department ON adp_active_department (userid);"); + + // Pass 1: the sender's active department, only where it is unambiguous. + Execute.Sql($@" +DO $$ +DECLARE affected integer; +BEGIN + LOOP + UPDATE messages m + SET departmentid = a.departmentid + FROM adp_active_department a + WHERE a.userid = m.sendinguserid + AND m.messageid IN ( + SELECT m2.messageid + FROM messages m2 + INNER JOIN adp_active_department a2 ON a2.userid = m2.sendinguserid + WHERE m2.departmentid IS NULL AND a2.departmentcount = 1 + LIMIT {BatchSize}); + + GET DIAGNOSTICS affected = ROW_COUNT; + EXIT WHEN affected = 0; + END LOOP; +END $$;"); + + // One vote per recipient membership, then the strict winner per message (tiedcount = 1). + Execute.Sql(@" +DROP TABLE IF EXISTS pg_temp.adp_recipient_votes; +CREATE TEMP TABLE adp_recipient_votes AS +SELECT mr.messageid, am.departmentid, COUNT(*) AS votes +FROM messagerecipients mr +INNER JOIN adp_active_membership am ON am.userid = mr.userid +GROUP BY mr.messageid, am.departmentid; + +CREATE INDEX ix_adp_recipient_votes ON adp_recipient_votes (messageid, departmentid); + +DROP TABLE IF EXISTS pg_temp.adp_recipient_consensus; +CREATE TEMP TABLE adp_recipient_consensus AS +SELECT v.messageid, MIN(v.departmentid) AS departmentid, COUNT(*) AS tiedcount +FROM adp_recipient_votes v +WHERE v.votes = (SELECT MAX(v2.votes) FROM adp_recipient_votes v2 WHERE v2.messageid = v.messageid) +GROUP BY v.messageid; + +CREATE UNIQUE INDEX ix_adp_recipient_consensus ON adp_recipient_consensus (messageid);"); + + // Pass 2: recipient majority for what pass 1 could not answer. Restricted to the sender's + // own memberships when they have any, so this disambiguates and never relocates. + Execute.Sql($@" +DO $$ +DECLARE affected integer; +BEGIN + LOOP + UPDATE messages m + SET departmentid = c.departmentid + FROM adp_recipient_consensus c + WHERE c.messageid = m.messageid + AND m.messageid IN ( + SELECT m2.messageid + FROM messages m2 + INNER JOIN adp_recipient_consensus c2 ON c2.messageid = m2.messageid AND c2.tiedcount = 1 + WHERE m2.departmentid IS NULL + AND (NOT EXISTS (SELECT 1 FROM adp_active_membership s WHERE s.userid = m2.sendinguserid) + OR EXISTS (SELECT 1 FROM adp_active_membership s + WHERE s.userid = m2.sendinguserid AND s.departmentid = c2.departmentid)) + LIMIT {BatchSize}); + + GET DIAGNOSTICS affected = ROW_COUNT; + EXIT WHEN affected = 0; + END LOOP; +END $$;"); + + // Pass 3: sanity check on pass 1. Only fires when NO recipient belongs to the department + // the sender's flags produced and the recipients agree on another department the sender + // is in too. Terminates because an updated row no longer differs from the consensus. + Execute.Sql($@" +DO $$ +DECLARE affected integer; +BEGIN + LOOP + UPDATE messages m + SET departmentid = c.departmentid + FROM adp_recipient_consensus c + WHERE c.messageid = m.messageid + AND m.messageid IN ( + SELECT m2.messageid + FROM messages m2 + INNER JOIN adp_recipient_consensus c2 ON c2.messageid = m2.messageid AND c2.tiedcount = 1 + INNER JOIN adp_active_membership s ON s.userid = m2.sendinguserid AND s.departmentid = c2.departmentid + WHERE m2.departmentid IS NOT NULL + AND m2.departmentid <> c2.departmentid + AND NOT EXISTS (SELECT 1 FROM adp_recipient_votes rv + WHERE rv.messageid = m2.messageid AND rv.departmentid = m2.departmentid) + LIMIT {BatchSize}); + + GET DIAGNOSTICS affected = ROW_COUNT; + EXIT WHEN affected = 0; + END LOOP; +END $$;"); + + // Pass 4: recipients inherit the parent message's answer. + Execute.Sql($@" +DO $$ +DECLARE affected integer; +BEGIN + LOOP + UPDATE messagerecipients mr + SET departmentid = m.departmentid + FROM messages m + WHERE m.messageid = mr.messageid + AND mr.messagerecipientid IN ( + SELECT mr2.messagerecipientid + FROM messagerecipients mr2 + INNER JOIN messages m2 ON m2.messageid = mr2.messageid + WHERE mr2.departmentid IS NULL AND m2.departmentid IS NOT NULL + LIMIT {BatchSize}); + + GET DIAGNOSTICS affected = ROW_COUNT; + EXIT WHEN affected = 0; + END LOOP; +END $$;"); + + Execute.Sql(@" +DROP TABLE IF EXISTS pg_temp.adp_recipient_consensus; +DROP TABLE IF EXISTS pg_temp.adp_recipient_votes; +DROP TABLE IF EXISTS pg_temp.adp_active_department; +DROP TABLE IF EXISTS pg_temp.adp_active_membership;"); + + RemoveInvalidIndexes(); + + // The migration engine batches by department and orders by primary key. + Execute.Sql("CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_messages_departmentid ON messages (departmentid, messageid);"); + Execute.Sql("CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_messagerecipients_departmentid ON messagerecipients (departmentid, messagerecipientid);"); + } + + public override void Down() + { + // Safe to reverse: the column is additive and, until these tables enter the catalog, + // nothing has been encrypted under the ownership it records. Refuse anyway if a + // protected row exists — at that point the column is load-bearing for decryption. + Execute.Sql(@" +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM messagerecipients WHERE isprotected = true) THEN + RAISE EXCEPTION 'M0137 rollback refused: protected messagerecipients rows exist and their envelopes are bound to departmentid.'; + END IF; +END $$;"); + + Execute.Sql("DROP INDEX CONCURRENTLY IF EXISTS ix_messagerecipients_departmentid;"); + Execute.Sql("DROP INDEX CONCURRENTLY IF EXISTS ix_messages_departmentid;"); + + if (Schema.Table("messagerecipients").Column("departmentid").Exists()) + Delete.Column("departmentid").FromTable("messagerecipients"); + + if (Schema.Table("messages").Column("departmentid").Exists()) + Delete.Column("departmentid").FromTable("messages"); + } + + 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 ( + 'ix_messages_departmentid', + 'ix_messagerecipients_departmentid') + 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/M0138_AddMessageRecipientPromptMetadataPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0138_AddMessageRecipientPromptMetadataPg.cs new file mode 100644 index 000000000..2503c266e --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0138_AddMessageRecipientPromptMetadataPg.cs @@ -0,0 +1,55 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Separates machine metadata from the member's own words on messagerecipients. + /// + /// note was doing two jobs: it held whatever a member typed when answering a message, AND the + /// TextResponsePromptMetadata token that says which calendar item or poll a prompt belongs to + /// ("calendar-rsvp:42", "poll:7"). That token is parsed by the chatbot inbound resolver, the + /// RSVP prompt service and both message controllers — paths that hold no Protected Data Grant, + /// against a broker whose workload lane is encrypt-only. So while the two shared a column, note + /// could not enter the protected-field catalog: encrypting it would have silently broken + /// calendar RSVP and poll replies for the departments that turned protection on. + /// + /// With the token in its own column, note becomes ordinary member free text and joins the + /// protected-field catalog with the rest of the message family (v7). promptmetadata stays + /// plaintext deliberately — it is a row pointer, holds nothing about a person, and every reader + /// of it runs without a grant. + /// + /// The backfill MOVES the token rather than copying it: leaving a duplicate behind would put a + /// machine token inside the column that is about to be encrypted, for no reader. + /// + [Migration(138)] + public class M0138_AddMessageRecipientPromptMetadataPg : Migration + { + public override void Up() + { + if (!Schema.Table("messagerecipients").Column("promptmetadata").Exists()) + Alter.Table("messagerecipients").AddColumn("promptmetadata").AsCustom("citext").Nullable(); + + // Only rows whose note IS a token — the prefixes are the ones TextResponsePromptMetadata + // writes. A member's typed note is left exactly where it is. + Execute.Sql(@" +UPDATE messagerecipients +SET promptmetadata = note, note = NULL +WHERE promptmetadata IS NULL + AND note IS NOT NULL + AND (note LIKE 'calendar-rsvp:%' OR note LIKE 'poll:%');"); + } + + public override void Down() + { + // Put the tokens back before the column disappears, or the prompts they point at become + // unanswerable. Only where note is free — a member's note must never be overwritten. + Execute.Sql(@" +UPDATE messagerecipients +SET note = promptmetadata, promptmetadata = NULL +WHERE promptmetadata IS NOT NULL AND (note IS NULL OR btrim(note::text) = '');"); + + if (Schema.Table("messagerecipients").Column("promptmetadata").Exists()) + Delete.Column("promptmetadata").FromTable("messagerecipients"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0139_AddModerationProtectionMarkersPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0139_AddModerationProtectionMarkersPg.cs new file mode 100644 index 000000000..3262d7fff --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0139_AddModerationProtectionMarkersPg.cs @@ -0,0 +1,51 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Row markers for the moderation family entering the protected-field catalog (ADP plan + /// section 5.3). isprotected says "this row's cataloged values carry rgdp envelopes" — the same + /// marker calls' children, unit states, member data and message recipients already carry. + /// + /// Only the marker is needed: every text column in these tables is citext (unbounded) from the + /// migrations that created them (M0107, M0112) and both binary payloads — originalcontent and + /// evidencecontent — are bytea, which holds an rgdpb envelope as-is. + /// + /// Additive and inert until the department is enrolled and the catalog-upgrade sweep runs. + /// + [Migration(139)] + public class M0139_AddModerationProtectionMarkersPg : Migration + { + private static readonly string[] Tables = + { + "moderationrequests", + "moderationreports", + "moderationactions", + "chatmessageflags", + "chatmoderationactions", + "chatexports" + }; + + public override void Up() + { + foreach (var table in Tables) + { + if (!Schema.Table(table).Column("isprotected").Exists()) + Alter.Table(table) + .AddColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false); + } + } + + public override void Down() + { + // The marker only. The values themselves stay exactly as they are: for an enrolled + // department these columns hold the ONLY copy of the moderated content, and dropping or + // narrowing them would destroy evidence that exists nowhere else. + foreach (var table in Tables) + { + if (Schema.Table(table).Column("isprotected").Exists()) + Delete.Column("isprotected").FromTable(table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0140_AddRemainingProtectionMarkersPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0140_AddRemainingProtectionMarkersPg.cs new file mode 100644 index 000000000..d2c386afd --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0140_AddRemainingProtectionMarkersPg.cs @@ -0,0 +1,48 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Row markers for the last of the plan's protected-field candidates (sections 5.2 and 22.1): + /// unit logs, user state notes, calendar items, documents, and the dormant mailbox credentials + /// on distribution lists. isprotected says "this row's cataloged values carry rgdp envelopes". + /// + /// Only the marker is needed. every text column here is citext (unbounded) and + /// documents.data is bytea, which holds an rgdpb envelope as-is. + /// + /// Additive and inert until the department is enrolled and the catalog-upgrade sweep runs. + /// + [Migration(140)] + public class M0140_AddRemainingProtectionMarkersPg : Migration + { + private static readonly string[] Tables = + { + "unitlogs", + "userstates", + "calendaritems", + "documents", + "distributionlists" + }; + + public override void Up() + { + foreach (var table in Tables) + { + if (!Schema.Table(table).Column("isprotected").Exists()) + Alter.Table(table) + .AddColumn("isprotected").AsBoolean().NotNullable().WithDefaultValue(false); + } + } + + public override void Down() + { + // The marker only: for an enrolled department the columns beside it hold the only copy + // of the values, and dropping or narrowing them would destroy data. + foreach (var table in Tables) + { + if (Schema.Table(table).Column("isprotected").Exists()) + Delete.Column("isprotected").FromTable(table); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0141_ContractLegacyMemberProfileDataPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0141_ContractLegacyMemberProfileDataPg.cs new file mode 100644 index 000000000..010ca4392 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0141_ContractLegacyMemberProfileDataPg.cs @@ -0,0 +1,84 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// CONTRACT phase of the expand/relocate/contract move of member profile data (ADP plan + /// section 5.1). M0132/M0133 expanded (department-scoped rows beside the global profile), + /// M0134 relocated every member, and the application has read and written the department copy + /// ever since. This removes the originals. + /// + /// A userprofile is GLOBAL to a person across every department they belong to, so it can never + /// be encrypted under one department's key — that is the whole reason the data moved. Leaving + /// the plaintext originals behind would mean an enrolled department encrypts a member's + /// identification number and address while an identical plaintext copy sits one table over. + /// + /// Three things happen, in this order and only in this order: + /// 1. REFUSE if any member still has legacy data that relocation has not stamped as moved. + /// A contract that runs early destroys the only copy, so this is a hard stop rather than + /// a comment. + /// 2. Delete the addresses rows that ONLY a user profile referenced. A member's home address + /// is now held department-scoped (and encrypted for an enrolled department); an orphaned + /// plaintext row in addresses would defeat that. Rows any contact, department, station or + /// department profile still points at are left completely alone. + /// 3. Clear the profile's address links and drop identificationnumber. + /// + /// The address links are CLEARED rather than dropped: they are ordinary nullable columns that + /// several code paths still read defensively, and a null reads the same as an absent column. + /// + [Migration(141)] + public class M0141_ContractLegacyMemberProfileDataPg : Migration + { + public override void Up() + { + Execute.Sql(@" +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM userprofiles up + INNER JOIN departmentmembers dm ON dm.userid = up.userid AND dm.isdeleted = false + LEFT JOIN departmentmembersensitivedata s + ON s.departmentid = dm.departmentid AND s.userid = up.userid + WHERE ( + (up.identificationnumber IS NOT NULL AND btrim(up.identificationnumber::text) <> '') + OR up.homeaddressid IS NOT NULL + OR up.mailingaddressid IS NOT NULL + ) + AND (s.departmentmembersensitivedataid IS NULL OR s.legacyprofilerelocatedon IS NULL)) THEN + RAISE EXCEPTION 'M0141 refused: members still hold legacy profile data that relocation has not stamped as moved. Run the member profile relocation to completion first - this migration destroys the originals.'; + END IF; +END $$;"); + + // Only addresses nothing else references. A shared row (a contact's, a station's) is + // left exactly as it is. + Execute.Sql(@" +DELETE FROM addresses a +WHERE EXISTS (SELECT 1 FROM userprofiles up + WHERE up.homeaddressid = a.addressid OR up.mailingaddressid = a.addressid) + AND NOT EXISTS (SELECT 1 FROM contacts c + WHERE c.physicaladdressid = a.addressid OR c.mailingaddressid = a.addressid) + AND NOT EXISTS (SELECT 1 FROM departments d WHERE d.addressid = a.addressid) + AND NOT EXISTS (SELECT 1 FROM departmentgroups g WHERE g.addressid = a.addressid) + AND NOT EXISTS (SELECT 1 FROM departmentprofiles p WHERE p.addressid = a.addressid);"); + + Execute.Sql(@" +UPDATE userprofiles +SET homeaddressid = NULL, mailingaddressid = NULL +WHERE homeaddressid IS NOT NULL OR mailingaddressid IS NOT NULL;"); + + if (Schema.Table("userprofiles").Column("identificationnumber").Exists()) + Delete.Column("identificationnumber").FromTable("userprofiles"); + } + + public override void Down() + { + // The column comes back empty. Its values moved to departmentmembersensitivedata long + // before this ran and are encrypted there for an enrolled department, so there is + // nothing to restore and nothing that could restore it — a rollback recreates the shape, + // not the data. The deleted addresses rows do not come back either. + if (!Schema.Table("userprofiles").Column("identificationnumber").Exists()) + Alter.Table("userprofiles").AddColumn("identificationnumber").AsCustom("citext").Nullable(); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0142_AddPolicyLastBillingEventIdPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0142_AddPolicyLastBillingEventIdPg.cs new file mode 100644 index 000000000..99b9b5a66 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0142_AddPolicyLastBillingEventIdPg.cs @@ -0,0 +1,29 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Records the last ADP addon billing event applied to a department (plan 17.2/17.3). + /// + /// Payment providers retry and duplicate webhooks, and they deliver out of order. Without a + /// record of what has already been applied, a replayed "cancelled" would re-schedule an + /// offboarding the member had since revoked. Storing the provider's event id makes the handler + /// idempotent against exactly that. + /// + [Migration(142)] + public class M0142_AddPolicyLastBillingEventIdPg : Migration + { + public override void Up() + { + if (!Schema.Table("departmentdataprotectionpolicies").Column("lastbillingeventid").Exists()) + Alter.Table("departmentdataprotectionpolicies") + .AddColumn("lastbillingeventid").AsCustom("citext").Nullable(); + } + + public override void Down() + { + if (Schema.Table("departmentdataprotectionpolicies").Column("lastbillingeventid").Exists()) + Delete.Column("lastbillingeventid").FromTable("departmentdataprotectionpolicies"); + } + } +} diff --git a/Tests/Resgrid.Tests/Bootstrapper.cs b/Tests/Resgrid.Tests/Bootstrapper.cs index ab484cb9f..01c53aee9 100644 --- a/Tests/Resgrid.Tests/Bootstrapper.cs +++ b/Tests/Resgrid.Tests/Bootstrapper.cs @@ -112,6 +112,10 @@ public static void Initialize() .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); protectedWriteStub.Setup(x => x.PrepareMemberEmergencyContactWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); + protectedWriteStub.Setup(x => x.PrepareMessageWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) + .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); + protectedWriteStub.Setup(x => x.PrepareMessageRecipientWriteAsync(Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny(), Moq.It.IsAny())) + .ReturnsAsync(Resgrid.Model.ProtectedWriteResult.Allowed()); builder.RegisterInstance(protectedWriteStub.Object) .As(); diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotTextResponseResolverTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotTextResponseResolverTests.cs index befae9ace..0eb254fbc 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotTextResponseResolverTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotTextResponseResolverTests.cs @@ -49,7 +49,7 @@ public async Task GetPendingResponses_ReturnsOnlyRecentUnansweredPolls() { MessageId = 1, UserId = "user-1", - Note = TextResponsePromptMetadata.ForPoll(10) + PromptMetadata = TextResponsePromptMetadata.ForPoll(10) }); _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(3, "user-1")) .ReturnsAsync(new MessageRecipient { MessageId = 3, UserId = "user-1", Response = "Yes" }); @@ -75,14 +75,14 @@ public async Task GetPendingResponses_ReturnsPollAndUnansweredCalendarRsvp() { MessageId = 4, UserId = "user-1", - Note = TextResponsePromptMetadata.ForPoll(10) + PromptMetadata = TextResponsePromptMetadata.ForPoll(10) }); _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(5, "user-1")) .ReturnsAsync(new MessageRecipient { MessageId = 5, UserId = "user-1", - Note = TextResponsePromptMetadata.ForCalendarRsvp(77) + PromptMetadata = TextResponsePromptMetadata.ForCalendarRsvp(77) }); _calendar.Setup(c => c.GetCalendarItemByIdAsync(77)).ReturnsAsync(new CalendarItem { @@ -116,7 +116,7 @@ public async Task GetPendingResponses_ExcludesPollFromAnotherDepartment() { MessageId = 4, UserId = "user-1", - Note = TextResponsePromptMetadata.ForPoll(20) + PromptMetadata = TextResponsePromptMetadata.ForPoll(20) }); // Act @@ -135,7 +135,7 @@ public async Task GetPendingResponses_ExcludesCalendarEventAlreadyAnswered() new Message { MessageId = 5, Type = (int)MessageTypes.CalendarRsvp, Subject = "Calendar RSVP: Drill", SentOn = now } }); _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(5, "user-1")) - .ReturnsAsync(new MessageRecipient { MessageId = 5, UserId = "user-1", Note = TextResponsePromptMetadata.ForCalendarRsvp(77) }); + .ReturnsAsync(new MessageRecipient { MessageId = 5, UserId = "user-1", PromptMetadata = TextResponsePromptMetadata.ForCalendarRsvp(77) }); _calendar.Setup(c => c.GetCalendarItemByIdAsync(77)).ReturnsAsync(new CalendarItem { CalendarItemId = 77, DepartmentId = 10, SignupType = (int)CalendarItemSignupTypes.RSVP @@ -191,7 +191,7 @@ public async Task RecordResponse_PollFromAnotherDepartment_DoesNotSaveResponse() { MessageId = 4, UserId = "user-1", - Note = TextResponsePromptMetadata.ForPoll(20) + PromptMetadata = TextResponsePromptMetadata.ForPoll(20) }; _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(4, "user-1")) .ReturnsAsync(recipient); @@ -252,7 +252,7 @@ public async Task PollCreateHandler_StoresDepartmentMetadataForEveryRecipient() response.Processed.Should().BeTrue(); saved.Should().NotBeNull(); saved.MessageRecipients.Should().HaveCount(2).And.OnlyContain(r => - r.Note == TextResponsePromptMetadata.ForPoll(10)); + r.PromptMetadata == TextResponsePromptMetadata.ForPoll(10)); } [Test] @@ -280,7 +280,7 @@ await service.RecordCalendarRsvpPromptAsync(new CalendarItem saved.ExpireOn.Should().BeCloseTo(saved.SentOn.AddDays(1), TimeSpan.FromSeconds(1)); saved.MessageRecipients.Should().ContainSingle(); saved.MessageRecipients.Should().ContainSingle(r => - r.UserId == "user-1" && r.Note == TextResponsePromptMetadata.ForCalendarRsvp(44)); + r.UserId == "user-1" && r.PromptMetadata == TextResponsePromptMetadata.ForCalendarRsvp(44)); } [Test] @@ -292,7 +292,7 @@ public async Task PromptService_ReusesMatchingUnexpiredCalendarPrompt() MessageRecipientId = 21, MessageId = 12, UserId = "user-1", - Note = TextResponsePromptMetadata.ForCalendarRsvp(44) + PromptMetadata = TextResponsePromptMetadata.ForCalendarRsvp(44) }; var existing = new Message { @@ -346,7 +346,7 @@ public async Task PromptService_CreatesNewPromptWhenMatchingPromptIsExpired() MessageRecipientId = 21, MessageId = 12, UserId = "user-1", - Note = TextResponsePromptMetadata.ForCalendarRsvp(44) + PromptMetadata = TextResponsePromptMetadata.ForCalendarRsvp(44) } } }; @@ -371,7 +371,7 @@ await service.RecordCalendarRsvpPromptAsync(new CalendarItem saved.Should().NotBeSameAs(expired); saved.MessageId.Should().Be(0); saved.MessageRecipients.Should().ContainSingle(r => - r.UserId == "user-1" && r.Note == TextResponsePromptMetadata.ForCalendarRsvp(44)); + r.UserId == "user-1" && r.PromptMetadata == TextResponsePromptMetadata.ForCalendarRsvp(44)); } [Test] diff --git a/Tests/Resgrid.Tests/Services/AdpAddonBillingReconciliationTests.cs b/Tests/Resgrid.Tests/Services/AdpAddonBillingReconciliationTests.cs new file mode 100644 index 000000000..4f213b54e --- /dev/null +++ b/Tests/Resgrid.Tests/Services/AdpAddonBillingReconciliationTests.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// The billing/data-safety state contract (ADP plan 17.3). The rule that matters most: no + /// billing event ever changes ciphertext. Cancellation SCHEDULES an offboarding migration and + /// that migration, later, is what decrypts — so a member who cancels keeps working protection + /// until the end of what they paid for. + /// + [TestFixture] + public class AdpAddonBillingReconciliationTests + { + private const int DeptId = 42; + + private Mock _policyRepo; + private Mock _cacheProvider; + private DepartmentDataProtectionPolicy _policy; + private DepartmentDataProtectionService _service; + + [SetUp] + public void SetUp() + { + _policy = new DepartmentDataProtectionPolicy + { + DepartmentDataProtectionPolicyId = 1, + DepartmentId = DeptId, + State = (int)DepartmentDataProtectionState.Enabled + }; + + _policyRepo = new Mock(); + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)).ReturnsAsync(() => _policy); + _policyRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentDataProtectionPolicy p, CancellationToken _, bool __) => p); + + // The state machine is a compare-and-swap in the repository; mirror it on the fake so a + // transition only "happens" from the expected state, exactly as the database enforces. + _policyRepo.Setup(x => x.TryTransitionStateAsync(DeptId, It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync((int _, DepartmentDataProtectionState from, DepartmentDataProtectionState to, + int? kind, string by, CancellationToken __) => + { + if ((DepartmentDataProtectionState)_policy.State != from) + return 0; + + _policy.State = (int)to; + return 1; + }); + + _cacheProvider = new Mock(); + _cacheProvider.Setup(x => x.RetrieveAsync(It.IsAny(), + It.IsAny>>(), It.IsAny())) + .Returns>, TimeSpan>((_, fallback, __) => fallback()); + _cacheProvider.Setup(x => x.RemoveAsync(It.IsAny())).ReturnsAsync(true); + + _service = new DepartmentDataProtectionService(_policyRepo.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + _cacheProvider.Object, + new ProtectedFieldCatalog(), + new Mock().Object); + } + + private static AdpAddonBillingEvent Event(AdpAddonBillingEventKind kind, string eventId = "evt-1") => + new AdpAddonBillingEvent + { + DepartmentId = DeptId, + Kind = kind, + ProviderEventId = eventId, + ProviderName = "Stripe", + ExternalSubscriptionRef = "sub_123", + OccurredOnUtc = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc), + EffectiveEndUtc = new DateTime(2027, 9, 1, 0, 0, 0, DateTimeKind.Utc) + }; + + [Test] + public async Task Activation_and_renewal_change_no_protection_state() + { + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.Activated)); + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.Enabled); + + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.Renewed, "evt-2")); + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.Enabled); + } + + [Test] + public async Task Cancellation_schedules_offboarding_for_the_end_of_the_paid_cycle() + { + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.Cancelled)); + + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.OffboardingScheduled); + _policy.OffboardingEffectiveOn.Should().Be(new DateTime(2027, 9, 1, 0, 0, 0, DateTimeKind.Utc), + "protection runs to the end of what was paid for"); + _policy.OffboardingSource.Should().Be((int)DepartmentDataProtectionOffboardingSource.UserCancelled); + } + + [Test] + public async Task A_chargeback_still_goes_through_the_offboarding_worker() + { + var chargeback = Event(AdpAddonBillingEventKind.Cancelled); + chargeback.IsChargeback = true; + chargeback.EffectiveEndUtc = null; + + await _service.ApplyAddonBillingEventAsync(chargeback); + + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.OffboardingScheduled, + "even a chargeback schedules the migration - it is never an instant crypto flip"); + _policy.OffboardingSource.Should().Be((int)DepartmentDataProtectionOffboardingSource.Chargeback); + } + + [Test] + public async Task A_payment_failure_leaves_protection_completely_alone() + { + var failure = Event(AdpAddonBillingEventKind.PaymentFailed); + failure.DunningState = "past_due"; + + await _service.ApplyAddonBillingEventAsync(failure); + + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.Enabled, + "protection continues during dunning; exhausted dunning arrives later as a cancellation"); + _policy.OffboardingEffectiveOn.Should().BeNull(); + } + + [Test] + public async Task A_replayed_cancellation_does_not_reschedule() + { + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.Cancelled)); + var scheduledFor = _policy.OffboardingEffectiveOn; + + // The member revokes it, then the provider redelivers the same webhook. + _policy.State = (int)DepartmentDataProtectionState.Enabled; + _policy.OffboardingEffectiveOn = null; + + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.Cancelled)); + + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.Enabled, + "the event id was already applied; a redelivery must not undo a revocation"); + _policy.OffboardingEffectiveOn.Should().BeNull(); + scheduledFor.Should().NotBeNull(); + } + + [Test] + public async Task A_renewal_after_a_cancellation_withdraws_the_scheduled_offboarding() + { + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.Cancelled)); + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.OffboardingScheduled); + + // Out-of-order delivery: the provider's current truth is that the subscription is alive. + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.Renewed, "evt-later")); + + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.Enabled); + _policy.OffboardingEffectiveOn.Should().BeNull(); + _policy.OffboardingSource.Should().BeNull(); + } + + [Test] + public async Task A_cancellation_mid_encryption_is_deferred_not_applied() + { + _policy.State = (int)DepartmentDataProtectionState.Encrypting; + + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.Cancelled)); + + ((DepartmentDataProtectionState)_policy.State).Should().Be(DepartmentDataProtectionState.Encrypting, + "the enrollment finishes to Enabled first (plan 21.3); nothing is abandoned half-encrypted"); + } + + [Test] + public async Task The_subscription_reference_is_recorded_for_the_operator() + { + await _service.ApplyAddonBillingEventAsync(Event(AdpAddonBillingEventKind.Activated)); + + _policy.AddonBillingReference.Should().Be("sub_123"); + _policy.LastBillingEventId.Should().Be("evt-1"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs index 96dacdf23..f1f6e0fbb 100644 --- a/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/AdpSizingServiceTests.cs @@ -55,9 +55,9 @@ public async Task Scan_counts_every_binding_and_derives_the_range_and_nights() "every catalog binding is counted"); result.TotalRows.Should().Be(10000L * AdpTableBindings.V1.Count); - // 130,000 rows / 100 rps = 1300s + 13×30s overhead = 1690s; ×1.25 = 2112.5s → 36 min P50. - result.EstimatedP50Minutes.Should().Be(36); - result.EstimatedP90Minutes.Should().Be(71); + // 260,000 rows / 100 rps = 2600s + 26×30s overhead = 3380s; ×1.25 = 4225s → 71 min P50. + result.EstimatedP50Minutes.Should().Be(71); + result.EstimatedP90Minutes.Should().Be(141); result.ProjectedNights.Should().Be(1, "the P90 estimate still fits one 480-minute window"); result.BenchmarkRowsPerSecond.Should().Be(100); } diff --git a/Tests/Resgrid.Tests/Services/CalendarExportProtectionTests.cs b/Tests/Resgrid.Tests/Services/CalendarExportProtectionTests.cs new file mode 100644 index 000000000..fb9033845 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/CalendarExportProtectionTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Moq; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// The iCal feed is fetched by a calendar application holding a revocable token and NO Protected + /// Data Grant — there is no step-up available to it, ever. So for a protected department an + /// entry keeps its shape (when it starts, how long, its reminder) and loses its content. What + /// must never happen is ciphertext arriving in someone's phone calendar. + /// + [TestFixture] + public class CalendarExportProtectionTests + { + private static string Export(CalendarItem item) + { + var calendarService = new Mock(); + calendarService.Setup(x => x.GetCalendarItemByIdAsync(item.CalendarItemId)).ReturnsAsync(item); + + var service = new CalendarExportService(calendarService.Object); + return service.GenerateICalForItemAsync(item.CalendarItemId).GetAwaiter().GetResult(); + } + + private static CalendarItem EnvelopedItem() => new CalendarItem + { + CalendarItemId = 42, + DepartmentId = 7, + Title = "rgdp:1:1:title==", + Description = "rgdp:1:1:description==", + Location = "rgdp:1:1:location==", + Start = new DateTime(2026, 9, 1, 18, 0, 0, DateTimeKind.Utc), + End = new DateTime(2026, 9, 1, 20, 0, 0, DateTimeKind.Utc) + }; + + [Test] + public void An_enveloped_entry_never_reaches_the_feed_as_ciphertext() + { + var feed = Export(EnvelopedItem()); + + feed.Should().NotContain("rgdp:", "a calendar application would render the envelope verbatim"); + feed.Should().NotContain("title=="); + feed.Should().NotContain("location=="); + } + + [Test] + public void The_entry_still_appears_so_the_calendar_lays_out() + { + var feed = Export(EnvelopedItem()); + + feed.Should().Contain("BEGIN:VEVENT"); + feed.Should().Contain("resgrid-cal-42@resgrid", + "the member has to see that something is scheduled, then open Resgrid for the detail"); + feed.Should().Contain("20260901T180000Z", "the scheduling columns are not cataloged"); + } + + [Test] + public void An_unprotected_entry_is_untouched() + { + var item = EnvelopedItem(); + item.Title = "Station 2 drill"; + item.Description = "Ladder evolutions"; + item.Location = "Training grounds"; + + var feed = Export(item); + + feed.Should().Contain("Station 2 drill"); + feed.Should().Contain("Training grounds"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs b/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs index 3f5ae2636..da0fa12fa 100644 --- a/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs +++ b/Tests/Resgrid.Tests/Services/CalendarServiceCheckInTests.cs @@ -60,11 +60,27 @@ public void SetUp() _encryptionService.Object, _checkInRepo.Object, _messageRecipientRepo.Object, - _unitOfWork.Object); + _unitOfWork.Object, + AllowedProtectedWrites()); } #region Service Logic Tests + /// + /// The ADP write net runs on every calendar item save (catalog v9). Stubbed to a plain allow + /// here - a loose mock returns a null Task and NREs at the await. + /// + private static Lazy AllowedProtectedWrites() + { + var stub = new Mock(); + stub.Setup(x => x.PrepareCalendarItemWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + + return new Lazy(() => stub.Object); + } + [Test] public async Task CheckInToEvent_creates_new_record_when_none_exists() { diff --git a/Tests/Resgrid.Tests/Services/CalendarServiceRsvpTransactionTests.cs b/Tests/Resgrid.Tests/Services/CalendarServiceRsvpTransactionTests.cs index 79f315d9a..5c626ab90 100644 --- a/Tests/Resgrid.Tests/Services/CalendarServiceRsvpTransactionTests.cs +++ b/Tests/Resgrid.Tests/Services/CalendarServiceRsvpTransactionTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using Moq; +using Resgrid.Model.Services; using NUnit.Framework; using Resgrid.Model; using Resgrid.Model.Repositories; @@ -37,7 +38,23 @@ public void SetUp() null, null, _messageRecipientRepository.Object, - _unitOfWork.Object); + _unitOfWork.Object, + AllowedProtectedWrites()); + } + + /// + /// The ADP write net runs on every calendar item save (catalog v9). Stubbed to a plain allow + /// here - a loose mock returns a null Task and NREs at the await. + /// + private static Lazy AllowedProtectedWrites() + { + var stub = new Mock(); + stub.Setup(x => x.PrepareCalendarItemWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + + return new Lazy(() => stub.Object); } [Test] diff --git a/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs b/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs index 9517759c6..49be48587 100644 --- a/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using FluentAssertions; using Moq; +using System.Threading; using NUnit.Framework; using Resgrid.Framework.Testing; using Resgrid.Model; @@ -18,6 +19,21 @@ namespace CalendarServiceTests { public class with_the_calendar_service : TestBase { + /// + /// The ADP write net runs on every calendar item save (catalog v9). Stubbed to a plain allow + /// here - a loose mock returns a null Task and NREs at the await. + /// + private static Lazy AllowedProtectedWrites() + { + var stub = new Mock(); + stub.Setup(x => x.PrepareCalendarItemWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + + return new Lazy(() => stub.Object); + } + protected ICalendarService _calendarService; protected Department _testDepartment; @@ -76,7 +92,7 @@ protected with_the_calendar_service() _userProfileServiceMock.Object, _departmentGroupsServiceMock.Object, _departmentSettingsServiceMock.Object, _encryptionServiceMock.Object, new Mock().Object, new Mock().Object, new Mock().Object, - _textResponsePromptServiceMock.Object); + AllowedProtectedWrites(), _textResponsePromptServiceMock.Object); } } diff --git a/Tests/Resgrid.Tests/Services/ChatModerationServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatModerationServiceTests.cs index 87e4a1eba..b1850e2f5 100644 --- a/Tests/Resgrid.Tests/Services/ChatModerationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatModerationServiceTests.cs @@ -58,7 +58,38 @@ public void SetUp() _channelService.Object, _permissionService.Object, auditService.Object, - Mock.Of()); + Mock.Of(), + AllowedProtectedWrites()); + } + + /// + /// The ADP write net runs on every moderation write (catalog v8). These tests are about + /// moderation behaviour, so it is stubbed to a plain allow - a loose mock returns a null Task + /// and NREs at the await. + /// + private static Lazy AllowedProtectedWrites() + { + var stub = new Mock(); + stub.Setup(x => x.PrepareModerationRequestWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + stub.Setup(x => x.PrepareModerationReportWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + stub.Setup(x => x.PrepareModerationActionWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + stub.Setup(x => x.PrepareChatMessageFlagWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + stub.Setup(x => x.PrepareChatModerationActionWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + stub.Setup(x => x.PrepareChatExportWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + + return new Lazy(() => stub.Object); } [Test] diff --git a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs index aba7437ca..d84d4b4a2 100644 --- a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs @@ -59,8 +59,13 @@ public void SetUpCommunicationService() // 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)); + .Setup(x => x.BuildNotificationSafeCallAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns((d, c, ch, culture) => Task.FromResult(c)); + _protectedProjectionServiceMock + .Setup(x => x.BuildNotificationSafeMessageAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns((d, m, ch, culture) => Task.FromResult(m)); _protectedProjectionServiceMock .Setup(x => x.IsChannelSanitizedAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(false); diff --git a/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs b/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs index e13dc64a7..1140e6268 100644 --- a/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DepartmentDataProtectionServiceTests.cs @@ -25,6 +25,7 @@ public class DepartmentDataProtectionServiceTests private Mock _featureToggleService; private Mock _subscriptionsService; private Mock _cacheProvider; + private Mock _migrationRepo; private DepartmentDataProtectionService _service; [SetUp] @@ -36,6 +37,7 @@ public void SetUp() _featureToggleService = new Mock(); _subscriptionsService = new Mock(); _cacheProvider = new Mock(); + _migrationRepo = new Mock(); // Cache pass-throughs so repository setups drive behavior. _cacheProvider @@ -61,7 +63,7 @@ public void SetUp() _service = new DepartmentDataProtectionService(_policyRepo.Object, _egressRepo.Object, _departmentsService.Object, _featureToggleService.Object, _subscriptionsService.Object, - _cacheProvider.Object, new ProtectedFieldCatalog()); + _cacheProvider.Object, new ProtectedFieldCatalog(), _migrationRepo.Object); } #region QueueEnrollment gates @@ -487,5 +489,51 @@ public async Task Enforcement_stays_on_after_any_failed_run() } #endregion -} + + #region Migration progress + + [Test] + public async Task Migration_progress_is_not_running_without_an_active_kind() + { + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)) + .ReturnsAsync(new DepartmentDataProtectionPolicy { DepartmentId = DeptId, State = (int)DepartmentDataProtectionState.Enabled }); + + var progress = await _service.GetMigrationProgressAsync(DeptId); + + progress.IsRunning.Should().BeFalse(); + progress.PercentComplete.Should().BeNull(); + } + + [Test] + public async Task Migration_progress_counts_rows_the_same_way_the_engine_does() + { + // Already-protected rows count as done: a resumed run passes over them without work, + // and a panel that ignored them would appear to stall. + _policyRepo.Setup(x => x.GetByDepartmentIdAsync(DeptId)) + .ReturnsAsync(new DepartmentDataProtectionPolicy + { + DepartmentId = DeptId, + State = (int)DepartmentDataProtectionState.Encrypting, + ActiveMigrationKind = (int)DepartmentDataProtectionMigrationKind.Enrollment + }); + + _migrationRepo.Setup(x => x.GetActiveByDepartmentIdAsync(DeptId, DepartmentDataProtectionMigrationKind.Enrollment)) + .ReturnsAsync(new[] + { + new DepartmentDataProtectionMigration { TargetTable = "Calls", RowsTotal = 100, RowsProcessed = 90 }, + new DepartmentDataProtectionMigration { TargetTable = "Contacts", RowsTotal = 100, RowsProcessed = 10, RowsAlreadyProtected = 10 } + }); + + var progress = await _service.GetMigrationProgressAsync(DeptId); + + progress.IsRunning.Should().BeTrue(); + progress.RowsTotal.Should().Be(200); + progress.RowsCompleted.Should().Be(110); + progress.PercentComplete.Should().Be(55); + progress.TablesStarted.Should().Be(2); + progress.CurrentTable.Should().Be("Contacts", "the least-finished table is the one still being worked"); + } + + #endregion Migration progress + } } diff --git a/Tests/Resgrid.Tests/Services/MemberEmergencyContactProtectionTests.cs b/Tests/Resgrid.Tests/Services/MemberEmergencyContactProtectionTests.cs index f22f71cff..936467644 100644 --- a/Tests/Resgrid.Tests/Services/MemberEmergencyContactProtectionTests.cs +++ b/Tests/Resgrid.Tests/Services/MemberEmergencyContactProtectionTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -123,7 +124,10 @@ public async Task Protected_department_repersists_the_enveloped_contact() It.IsAny(), null, null, true, It.IsAny())) .ReturnsAsync(ProtectedWriteResult.Allowed(isProtected: true, changed: true)); - await _service.SaveAsync(BuildContact()); + // A NEW row: its identity pk is the AAD row key, so it cannot be enveloped until the + // insert has happened, and the envelopes then need a second write. An existing row takes + // the pre-save path instead and is saved exactly once. + await _service.SaveAsync(BuildContact(0)); _repo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2), "initial save plus the re-save that persists the envelopes"); @@ -166,6 +170,73 @@ public async Task Delete_refuses_an_id_that_belongs_to_another_member() _repo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + /// + /// The AAD row key is the identity pk, so a NEW row has to reach the table before it can be + /// enveloped — that first insert is unavoidably plaintext. An EDIT has no such excuse: the + /// id already exists, so the values are enveloped BEFORE the write and next-of-kin details + /// never sit in the table in the clear, not even for the width of one statement. + /// + [Test] + public async Task An_existing_row_is_enveloped_before_it_is_saved() + { + var order = new List(); + + _protectedWriteService.Setup(x => x.PrepareMemberEmergencyContactWriteAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()) + .Callback(() => order.Add("protect")); + + _repo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentMemberEmergencyContact c, CancellationToken _, bool __) => c) + .Callback(() => order.Add("save")); + + await _service.SaveAsync(BuildContact(7)); + + order.Should().Equal(new[] { "protect", "save" }); + } + + [Test] + public async Task A_new_row_is_saved_first_because_the_row_key_does_not_exist_yet() + { + var order = new List(); + + _protectedWriteService.Setup(x => x.PrepareMemberEmergencyContactWriteAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()) + .Callback(() => order.Add("protect")); + + _repo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentMemberEmergencyContact c, CancellationToken _, bool __) => + { + c.DepartmentMemberEmergencyContactId = 42; + return c; + }) + .Callback(() => order.Add("save")); + + await _service.SaveAsync(BuildContact(0)); + + order.Should().Equal(new[] { "save", "protect" }); + } + + [Test] + public void A_blocked_write_on_an_existing_row_saves_nothing_at_all() + { + // The pre-save path can fail closed WITHOUT leaving a transient plaintext row, which the + // post-save path cannot. The message must not claim otherwise. + _protectedWriteService.Setup(x => x.PrepareMemberEmergencyContactWriteAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Blocked("broker_unavailable")); + + Func save = () => _service.SaveAsync(BuildContact(9)); + + save.Should().ThrowAsync().WithMessage("*was NOT saved*"); + _repo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task Delete_is_a_soft_delete_so_envelopes_survive() { diff --git a/Tests/Resgrid.Tests/Services/MessageDepartmentOwnershipTests.cs b/Tests/Resgrid.Tests/Services/MessageDepartmentOwnershipTests.cs new file mode 100644 index 000000000..d471bb5aa --- /dev/null +++ b/Tests/Resgrid.Tests/Services/MessageDepartmentOwnershipTests.cs @@ -0,0 +1,158 @@ +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.Queue; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Messages and their recipients gained their own DepartmentId in M0137, because the ADP + /// envelope AAD binds the department and these rows previously had no department at all — only + /// user ids, and a user can move between departments. + /// + /// M0137 backfills the history. These pin the other half: every row written from now on must + /// carry the owner, or the column silently refills with unattributable rows and the tables can + /// never be bound to the catalog. + /// + [TestFixture] + public class MessageDepartmentOwnershipTests + { + private const int DeptId = 42; + + private Mock _messageRepository; + private Mock _recipientRepository; + private Mock _queueService; + private Mock _userProfileService; + private Mock _protectedWriteService; + private MessageService _service; + + [SetUp] + public void SetUp() + { + _messageRepository = new Mock(); + _messageRepository.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((Message m, CancellationToken _, bool __) => m); + + _recipientRepository = new Mock(); + _recipientRepository.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((MessageRecipient r, CancellationToken _, bool __) => r); + + _queueService = new Mock(); + _queueService.Setup(x => x.EnqueueMessageBroadcastAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + _userProfileService = new Mock(); + _userProfileService.Setup(x => x.GetSelectedUserProfilesAsync(It.IsAny>())) + .ReturnsAsync(new List()); + + _protectedWriteService = new Mock(); + _protectedWriteService.Setup(x => x.PrepareMessageWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + _protectedWriteService.Setup(x => x.PrepareMessageRecipientWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + + _service = new MessageService(_messageRepository.Object, null, null, _queueService.Object, + _userProfileService.Object, _recipientRepository.Object, + new Lazy(() => _protectedWriteService.Object)); + } + + [Test] + public async Task Sending_stamps_the_department_on_a_message_that_lacks_one() + { + var message = new Message { MessageId = 7, Subject = "s", Body = "b", SentOn = DateTime.UtcNow }; + + await _service.SendMessageAsync(message, "sender", DeptId, broadcastSingle: false); + + message.DepartmentId.Should().Be(DeptId); + + // The row was already saved by the caller, so the owner has to be persisted, not just + // set on the instance being queued. + _messageRepository.Verify(x => x.SaveOrUpdateAsync(It.Is(m => m.DepartmentId == DeptId), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Sending_never_relocates_a_message_that_already_has_an_owner() + { + var message = new Message { MessageId = 7, DepartmentId = 9, Subject = "s", Body = "b", SentOn = DateTime.UtcNow }; + + await _service.SendMessageAsync(message, "sender", DeptId, broadcastSingle: false); + + message.DepartmentId.Should().Be(9, "the owner is resolved once and frozen - envelopes are bound to it"); + _messageRepository.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task Broadcast_copies_each_carry_the_department() + { + var message = new Message + { + Subject = "s", + Body = "b", + SentOn = DateTime.UtcNow, + SendingUserId = "sender-1", + Recipients = "user-1|user-2" + }; + + await _service.SendMessageAsync(message, "sender", DeptId); + + _messageRepository.Verify(x => x.SaveOrUpdateAsync(It.Is(m => m.DepartmentId == DeptId), + It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Test] + public async Task Saving_a_message_pushes_the_owner_onto_its_cascade_saved_recipients() + { + // RepositoryBase cascade-saves the collection, so the children are written by the same + // call that writes the parent - they have to be stamped before it, not after. + var message = new Message { DepartmentId = DeptId, Subject = "s", Body = "b", SentOn = DateTime.UtcNow }; + message.AddRecipient("user-1"); + message.AddRecipient("user-2"); + + await _service.SaveMessageAsync(message); + + message.MessageRecipients.Should().OnlyContain(r => r.DepartmentId == DeptId); + } + + [Test] + public async Task A_recipient_saved_on_its_own_inherits_the_owner_from_its_message() + { + _messageRepository.Setup(x => x.GetMessagesByMessageIdAsync(7)) + .ReturnsAsync(new Message { MessageId = 7, DepartmentId = DeptId }); + + var recipient = new MessageRecipient { MessageRecipientId = 3, MessageId = 7, UserId = "user-1" }; + + await _service.SaveMessageRecipientAsync(recipient); + + recipient.DepartmentId.Should().Be(DeptId); + } + + [Test] + public async Task A_recipient_that_already_has_an_owner_does_not_read_its_parent() + { + var recipient = new MessageRecipient + { + MessageRecipientId = 3, + MessageId = 7, + UserId = "user-1", + DepartmentId = DeptId + }; + + await _service.SaveMessageRecipientAsync(recipient); + + _messageRepository.Verify(x => x.GetMessagesByMessageIdAsync(It.IsAny()), Times.Never, + "rows written after M0137 already carry the owner; the lookup is for the historic tail only"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/MessageProtectionTests.cs b/Tests/Resgrid.Tests/Services/MessageProtectionTests.cs new file mode 100644 index 000000000..d4f8065e3 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/MessageProtectionTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Catalog v7 brought member messaging into the protected-field catalog. These pin the shape of + /// that binding — which columns are in, which are deliberately out, and that the two tables are + /// scoped by their own DepartmentId rather than a join (M0137). + /// + [TestFixture] + public class MessageProtectionTests + { + private ProtectedFieldCatalog _catalog; + + [SetUp] + public void SetUp() => _catalog = new ProtectedFieldCatalog(); + + [Test] + public void Messaging_is_what_moved_the_catalog_to_seven() + { + _catalog.Version.Should().BeGreaterThanOrEqualTo(7); + + _catalog.GetAddedBetween(6, 7).Select(e => e.FieldId) + .Should().BeEquivalentTo(new[] + { + "messages.subject", + "messages.body", + "messagerecipients.response", + "messagerecipients.note", + "messagerecipients.latitude", + "messagerecipients.longitude" + }); + } + + /// + /// The note is cataloged and the prompt metadata that used to share its column is NOT, + /// and it must stay that way. Every reader of that token — the chatbot inbound resolver, the + /// RSVP prompt service, both message controllers — runs with NO grant, against a broker + /// whose workload lane is encrypt-only, so enveloping it would silently break calendar RSVP + /// and poll replies for exactly the departments that turned protection on. + /// + [Test] + public void The_note_is_cataloged_and_the_prompt_metadata_beside_it_is_not() + { + var fieldIds = _catalog.GetAll().Select(e => e.FieldId).ToList(); + + fieldIds.Should().Contain("messagerecipients.note"); + fieldIds.Should().NotContain("messagerecipients.promptmetadata", + "the token is a row pointer read without a grant; encrypting it breaks RSVP and polls"); + + var columns = AdpTableBindings.V1.Single(b => b.TableName == "MessageRecipients") + .Columns.Select(c => c.FieldId).ToList(); + + columns.Should().Contain("messagerecipients.note"); + columns.Should().NotContain("messagerecipients.promptmetadata"); + } + + [Test] + public void Both_message_tables_are_scoped_by_their_own_department_column() + { + foreach (var table in new[] { "Messages", "MessageRecipients" }) + { + var binding = AdpTableBindings.V1.Single(b => b.TableName == table); + + binding.DepartmentColumn.Should().Be("DepartmentId", + $"{table} got its own department column in M0137 precisely so the AAD has a value to bind"); + binding.ParentTable.Should().BeNull($"{table} no longer derives ownership through a parent"); + } + } + + [Test] + public void The_recipient_marker_column_is_bound_and_the_message_one_is_not() + { + // M0129 added IsProtected to MessageRecipients only; Messages detects the envelope + // prefix on read instead, exactly as Calls and Contacts do. + AdpTableBindings.V1.Single(b => b.TableName == "MessageRecipients") + .ProtectedMarkerColumn.Should().Be("IsProtected"); + AdpTableBindings.V1.Single(b => b.TableName == "Messages") + .ProtectedMarkerColumn.Should().BeNull(); + } + + [Test] + public void Recipient_coordinates_are_bound_as_companion_columns() + { + var columns = AdpTableBindings.V1.Single(b => b.TableName == "MessageRecipients").Columns + .Where(c => c.StorageKind == ProtectedFieldStorageKind.CompanionColumn) + .ToList(); + + columns.Select(c => c.CompanionColumn) + .Should().BeEquivalentTo(new[] { "ProtectedLatitudeEnvelope", "ProtectedLongitudeEnvelope" }, + "a decimal column cannot hold an rgdp envelope, so the value moves to its companion"); + } + + [Test] + public void A_catalog_upgrade_from_six_touches_only_the_message_tables() + { + var scoped = AdpTableBindings.ForVersionRange(_catalog, 6, 7); + + scoped.Select(b => b.TableName) + .Should().BeEquivalentTo(new[] { "Messages", "MessageRecipients" }, + "an upgrade sweep must not re-read a table it has nothing to do in"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs b/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs index 066053a42..6d77d6d5e 100644 --- a/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs +++ b/Tests/Resgrid.Tests/Services/MessageServiceInboxTests.cs @@ -9,6 +9,7 @@ using Resgrid.Model; using Resgrid.Model.Messages; using Resgrid.Model.Repositories; +using Resgrid.Model.Services; using Resgrid.Repositories.DataRepository.Configs; using Resgrid.Repositories.DataRepository.Queries.Messages; using Resgrid.Repositories.DataRepository.Servers.SqlServer; @@ -20,6 +21,23 @@ namespace Resgrid.Tests.Services [TestFixture] public class MessageServiceInboxTests { + /// + /// The ADP write net runs on every message save (catalog v7). These tests are about inbox + /// behaviour, so the net is stubbed to a plain allow - a loose mock would hand back a null + /// Task and NRE at the await. + /// + private static Lazy AllowedWrites() + { + var stub = new Mock(); + stub.Setup(x => x.PrepareMessageWriteAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + stub.Setup(x => x.PrepareMessageRecipientWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + + return new Lazy(() => stub.Object); + } [Test] public async Task SaveMessageTruncatesValuesToDatabaseColumnLengths() { @@ -27,7 +45,7 @@ public async Task SaveMessageTruncatesValuesToDatabaseColumnLengths() repository .Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((Message savedMessage, CancellationToken _, bool _) => savedMessage); - var service = new MessageService(repository.Object, null, null, null, null, null); + var service = new MessageService(repository.Object, null, null, null, null, null, AllowedWrites()); var message = new Message { Subject = new string('s', Message.MaximumSubjectLength + 1), @@ -61,7 +79,7 @@ public async Task InboxAndUnreadCountExcludeExpiredMessages() .ReturnsAsync(messages); repository.Setup(x => x.GetUnreadMessageCountAsync("user-1")) .ReturnsAsync(2); - var service = new MessageService(repository.Object, null, null, null, null, null); + var service = new MessageService(repository.Object, null, null, null, null, null, AllowedWrites()); var inbox = await service.GetInboxMessagesByUserIdAsync("user-1"); var unreadCount = await service.GetUnreadMessagesCountByUserIdAsync("user-1"); @@ -80,7 +98,7 @@ public void ApiMappingUsesSystemSenderAndExposesCalendarRsvpMetadataForCurrentUs message.SystemGenerated = true; message.SendingUserId = "user-1"; message.Type = (int)MessageTypes.CalendarRsvp; - message.MessageRecipients.First().Note = TextResponsePromptMetadata.ForCalendarRsvp(8521); + message.MessageRecipients.First().PromptMetadata = TextResponsePromptMetadata.ForCalendarRsvp(8521); message.MessageRecipients.First().Response = "Yes"; var result = MessagesController.ConvertMessageResultData(message, null, "user-1", null); diff --git a/Tests/Resgrid.Tests/Services/ModerationProtectionTests.cs b/Tests/Resgrid.Tests/Services/ModerationProtectionTests.cs new file mode 100644 index 000000000..bd30c48c2 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/ModerationProtectionTests.cs @@ -0,0 +1,146 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Catalog v8 brings moderation into the protected-field catalog (plan 5.3). A moderation record + /// is a verbatim copy of the worst content a department holds — the reported message, the file + /// that came with it, the reporter's words and the moderator's account of why. Leaving it in the + /// clear would mean a protected department encrypts the original and keeps a plaintext duplicate + /// one table over, reachable by anyone who can open the queue. + /// + [TestFixture] + public class ModerationProtectionTests + { + private static readonly string[] ModerationTables = + { + "ModerationRequests", "ModerationReports", "ModerationActions", + "ChatMessageFlags", "ChatModerationActions", "ChatExports" + }; + + private ProtectedFieldCatalog _catalog; + + [SetUp] + public void SetUp() => _catalog = new ProtectedFieldCatalog(); + + [Test] + public void Moderation_is_what_moved_the_catalog_to_eight() + { + _catalog.Version.Should().BeGreaterThanOrEqualTo(8); + + _catalog.GetAddedBetween(7, 8).Select(e => e.FieldId) + .Should().BeEquivalentTo(new[] + { + "moderationrequests.originalsubject", + "moderationrequests.originaltext", + "moderationrequests.originalfilename", + "moderationrequests.originalcontenttype", + "moderationrequests.originalcontent", + "moderationrequests.originalmetadatajson", + "moderationrequests.adminnote", + "moderationreports.note", + "moderationactions.note", + "moderationactions.detailsjson", + "moderationactions.evidencetext", + "moderationactions.evidencecontent", + "moderationactions.evidencemetadatajson", + "chatmessageflags.note", + "chatmessageflags.resolutionnote", + "chatmoderationactions.reason", + "chatmoderationactions.detailsjson", + "chatexports.data", + "chatexports.error" + }); + } + + /// + /// The moderation queue has to stay usable without a grant: a moderator triages by status, + /// reason CODE and counts long before they need to read the excerpt. Those columns are + /// structural (section 5.4) and must never be encrypted, or the queue itself stops working + /// for a protected department. + /// + [Test] + public void Triage_columns_stay_plaintext() + { + var fieldIds = _catalog.GetAll().Select(e => e.FieldId).ToList(); + + foreach (var structural in new[] + { + "moderationrequests.status", "moderationrequests.disposition", "moderationrequests.itemtype", + "moderationreports.reason", "chatmessageflags.reason", "chatmessageflags.status", + "chatexports.status", "chatexports.format" + }) + { + fieldIds.Should().NotContain(structural); + } + } + + /// + /// The action row records WHO moderated and from where. That is the audit trail used to + /// investigate abuse of the moderation tools themselves, it is not the reported content, and + /// encrypting it would blind the very trail it exists to provide (plan 5.4). + /// + [Test] + public void The_moderator_audit_trail_stays_readable() + { + var fieldIds = _catalog.GetAll().Select(e => e.FieldId).ToList(); + + foreach (var audit in new[] + { + "moderationactions.actorrole", "moderationactions.ipaddress", "moderationactions.useragent", + "moderationactions.traceid", "moderationactions.servername" + }) + { + fieldIds.Should().NotContain(audit); + } + + ProtectedReadService.ModerationActionFieldAccessors.Keys + .Should().NotContain("moderationactions.ipaddress"); + } + + [Test] + public void Every_moderation_table_is_scoped_by_its_own_department_column_and_marked() + { + foreach (var table in ModerationTables) + { + var binding = AdpTableBindings.V1.Single(b => b.TableName == table); + + binding.DepartmentColumn.Should().Be("DepartmentId", + $"{table} carries its own department, so the sweep never has to join to find the owner"); + binding.ParentTable.Should().BeNull(); + binding.ProtectedMarkerColumn.Should().Be("IsProtected", "M0139 added the marker to every one of them"); + } + } + + [Test] + public void The_reported_file_and_the_export_archive_are_bound_as_binary() + { + var binaries = AdpTableBindings.V1 + .Where(b => ModerationTables.Contains(b.TableName)) + .SelectMany(b => b.Columns) + .Where(c => c.StorageKind == ProtectedFieldStorageKind.Binary) + .Select(c => c.FieldId) + .ToList(); + + binaries.Should().BeEquivalentTo(new[] + { + "moderationrequests.originalcontent", + "moderationactions.evidencecontent", + "chatexports.data" + }, "an rgdpb envelope, not a text one - these are files and archives"); + } + + [Test] + public void A_catalog_upgrade_from_seven_touches_only_the_moderation_tables() + { + var scoped = AdpTableBindings.ForVersionRange(_catalog, 7, 8); + + scoped.Select(b => b.TableName).Should().BeEquivalentTo(ModerationTables, + "an upgrade sweep must not re-read a table it has nothing to do in"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/ModerationServiceTests.cs b/Tests/Resgrid.Tests/Services/ModerationServiceTests.cs index 3d6037839..0695027ca 100644 --- a/Tests/Resgrid.Tests/Services/ModerationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ModerationServiceTests.cs @@ -580,13 +580,43 @@ public async Task ReporterRequestBatchUsesSingleScopedRepositoryLookup() _actions.Verify(x => x.GetByRequestIdsAsync(It.IsAny>()), Times.Never); } + /// + /// The ADP write net runs on every moderation write (catalog v8). These tests are about + /// moderation behaviour, so it is stubbed to a plain allow - a loose mock returns a null Task + /// and NREs at the await. + /// + private static Lazy AllowedProtectedWrites() + { + var stub = new Mock(); + stub.Setup(x => x.PrepareModerationRequestWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + stub.Setup(x => x.PrepareModerationReportWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + stub.Setup(x => x.PrepareModerationActionWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + stub.Setup(x => x.PrepareChatMessageFlagWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + stub.Setup(x => x.PrepareChatModerationActionWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + stub.Setup(x => x.PrepareChatExportWriteAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(ProtectedWriteResult.Allowed()); + + return new Lazy(() => stub.Object); + } + private ModerationService CreateService() { return new ModerationService(_requests.Object, _reports.Object, _actions.Object, _chatMessages.Object, _chatAttachments.Object, _chatChannels.Object, _chatPermissions.Object, _chatMessageService.Object, _messages.Object, _callNotes.Object, _callAttachments.Object, _calls.Object, _groups.Object, _authorization.Object, _audit.Object, _userProfiles.Object, - _unitOfWork.Object, _outboundQueue.Object); + _unitOfWork.Object, _outboundQueue.Object, AllowedProtectedWrites()); } private void SetupMessageEvidence() diff --git a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs index 03233b3ce..4679a3f64 100644 --- a/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ProtectedReadServiceTests.cs @@ -272,13 +272,17 @@ public void Every_bound_table_either_has_read_accessors_or_is_explicitly_exclude { // Migrated (so their columns DO get enveloped) but not read back through this service. // Anything added here needs its own safe-display or reveal path before it is read. - var withoutReadAccessors = new[] { "CallReferences" }; + var withoutReadAccessors = Array.Empty(); var covered = new[] { "Calls", "CallNotes", "CallAttachments", "Contacts", "ContactNotes", "UnitStates", "UdfFieldValues", "Logs", "DepartmentMemberSensitiveData", - "DepartmentMemberEmergencyContacts", "PersonnelCertifications", "CallLogs" + "DepartmentMemberEmergencyContacts", "PersonnelCertifications", "CallLogs", "CallReferences", + "Messages", "MessageRecipients", + "ModerationRequests", "ModerationReports", "ModerationActions", + "ChatMessageFlags", "ChatModerationActions", "ChatExports", + "UnitLogs", "UserStates", "CalendarItems", "Documents", "DistributionLists" }; AdpTableBindings.V1.Select(b => b.TableName) @@ -337,8 +341,129 @@ public async Task Contacts_reveal_with_a_valid_grant_in_one_broker_batch() It.IsAny>(), It.IsAny()), Times.Once); } + [Test] + public async Task Messages_redact_without_a_grant_and_carry_their_recipients_along() + { + var message = new Message + { + MessageId = 11, + DepartmentId = DeptId, + Subject = "rgdp:1:1:subject==", + Body = "rgdp:1:1:body==", + MessageRecipients = new List + { + new MessageRecipient + { + MessageRecipientId = 21, + DepartmentId = DeptId, + Response = "rgdp:1:1:response==", + ProtectedLatitudeEnvelope = "rgdp:1:1:lat==" + } + } + }; + + var result = await _service.ResolveMessagesForReadAsync(DeptId, new[] { message }, null, UserId); + + result.IsProtected.Should().BeTrue(); + result.ProtectedReason.Should().Be("step_up_required"); + message.Subject.Should().Be(ProtectedDataEnvelope.RedactionValue); + message.Body.Should().Be(ProtectedDataEnvelope.RedactionValue); + message.MessageRecipients.First().Response.Should().Be(ProtectedDataEnvelope.RedactionValue); + message.MessageRecipients.First().Latitude.Should().BeNull("a concealed coordinate stays null, never a placeholder in a decimal"); + result.RedactedFields.Should().Contain("messages.subject").And.Contain("messagerecipients.response"); + } + + [Test] + public async Task Messages_and_their_recipients_reveal_in_one_broker_batch() + { + var message = new Message + { + MessageId = 11, + DepartmentId = DeptId, + Subject = "rgdp:1:1:subject==", + Body = "rgdp:1:1:body==", + MessageRecipients = new List + { + new MessageRecipient { MessageRecipientId = 21, DepartmentId = DeptId, Response = "rgdp:1:1:response==" } + } + }; + + _brokerClient.Setup(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny())) + .ReturnsAsync((int d, string g, string r, IReadOnlyList items, CancellationToken ct) => + new ProtectedDataBrokerResult + { + Success = true, + Items = items.Select(i => new ProtectedFieldOperationResult + { + FieldId = i.FieldId, + RowKey = i.RowKey, + Value = i.FieldId == "messages.subject" ? "Shift swap" + : i.FieldId == "messages.body" ? "Can you cover Saturday?" + : "YES" + }).ToList() + }); + + var result = await _service.ResolveMessagesForReadAsync(DeptId, new[] { message }, IssueGrant(), UserId); + + result.ProtectedReason.Should().BeNull(); + message.Subject.Should().Be("Shift swap"); + message.Body.Should().Be("Can you cover Saturday?"); + message.MessageRecipients.First().Response.Should().Be("YES"); + _brokerClient.Verify(x => x.DecryptAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), It.IsAny()), Times.Once, + "the conversation resolves in one round trip, not one call per row"); + } + // ── protected writes ───────────────────────────────────────────────────── + [Test] + public async Task A_message_write_envelopes_the_subject_and_the_body() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + SetupCurrentCatalogVersion(); + + var message = new Message { MessageId = 11, DepartmentId = DeptId, Subject = "Shift swap", Body = "Saturday?" }; + + var result = await _service.PrepareMessageWriteAsync(DeptId, message, null, null, workloadCaller: true); + + result.Success.Should().BeTrue(); + result.Changed.Should().BeTrue(); + message.Subject.Should().Be("rgdp:1:1:messages.subject=="); + message.Body.Should().Be("rgdp:1:1:messages.body=="); + } + + [Test] + public async Task A_recipient_write_moves_the_coordinates_into_their_companion_columns() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + SetupCurrentCatalogVersion(); + + var recipient = new MessageRecipient + { + MessageRecipientId = 21, + DepartmentId = DeptId, + Response = "YES", + Note = "Running 10 minutes late", + PromptMetadata = "calendar-rsvp:5", + Latitude = 39.7392m, + Longitude = -104.9903m + }; + + var result = await _service.PrepareMessageRecipientWriteAsync(DeptId, recipient, null, null, workloadCaller: true); + + result.Success.Should().BeTrue(); + recipient.Response.Should().Be("rgdp:1:1:messagerecipients.response=="); + recipient.ProtectedLatitudeEnvelope.Should().Be("rgdp:1:1:messagerecipients.latitude=="); + recipient.Latitude.Should().BeNull("the typed column is emptied once the value lives in its companion"); + recipient.IsProtected.Should().BeTrue(); + recipient.Note.Should().Be("rgdp:1:1:messagerecipients.note==", "the note is cataloged with the rest of the message family"); + recipient.PromptMetadata.Should().Be("calendar-rsvp:5", + "the prompt token is read by grantless paths and is deliberately never cataloged"); + } + private void SetupWriteEnforced(bool enforced = true) { _dataProtectionService.Setup(x => x.ShouldEncryptNewWritesAsync(DeptId)).ReturnsAsync(enforced); @@ -455,7 +580,7 @@ public async Task Redacted_sentinel_on_an_edit_restores_the_stored_envelope() } [Test] - public async Task Redacted_sentinel_without_a_stored_row_is_never_encrypted() + public async Task Redacted_sentinel_without_a_stored_row_is_neutralized_not_stored() { SetupWriteEnforced(); IReadOnlyList sentItems = null; @@ -482,7 +607,12 @@ public async Task Redacted_sentinel_without_a_stored_row_is_never_encrypted() result.Success.Should().BeTrue(); sentItems.Select(i => i.FieldId).Should().BeEquivalentTo(new[] { "calls.natureofcall" }, "the placeholder must never be enveloped — that would destroy the original"); - call.Name.Should().Be(ProtectedDataEnvelope.RedactionValue); + + // And it must not survive as the literal word either. These nets run AFTER the row was + // saved, so leaving "REDACTED" in the field means it is what sits in the database, and a + // later save encrypts it and makes the loss permanent. With no stored row there is + // nothing to restore, so the honest outcome is an empty field. + call.Name.Should().BeNull("with nothing to restore, the placeholder is cleared rather than stored"); } [Test] @@ -777,6 +907,60 @@ public async Task Certification_sentinel_restore_reports_changed_so_the_caller_r edited.Number.Should().Be("rgdp:1:1:stored-number=="); } + /// + /// The sentinel has destroyed data four times in this codebase — member addresses, emergency + /// contacts, UDF values and certifications — because nine of the twelve write paths only + /// refused to ENCRYPT it and let the literal word stay in the row the service had already + /// saved. This pins the policy centrally so a new write path inherits it. + /// + [Test] + public async Task No_write_path_persists_the_literal_placeholder() + { + SetupWriteEnforced(); + SetupEncryptEcho(); + SetupCurrentCatalogVersion(); + + var note = new CallNote { CallNoteId = 3, Note = ProtectedDataEnvelope.RedactionValue }; + await _service.PrepareCallNoteWriteAsync(DeptId, note, IssueGrant(), UserId, workloadCaller: false); + note.Note.Should().BeNull("a call note has no stored row to restore from, so the placeholder is cleared"); + + var log = new Log { LogId = 4, Narrative = ProtectedDataEnvelope.RedactionValue }; + await _service.PrepareLogWriteAsync(DeptId, log, IssueGrant(), UserId, workloadCaller: false); + log.Narrative.Should().BeNull(); + + var state = new UnitState { UnitStateId = 5, Note = ProtectedDataEnvelope.RedactionValue }; + await _service.PrepareUnitStateWriteAsync(DeptId, state, IssueGrant(), UserId, workloadCaller: false); + state.Note.Should().BeNull(); + + var contact = new DepartmentMemberEmergencyContact + { + DepartmentMemberEmergencyContactId = 6, + DepartmentId = DeptId, + Name = ProtectedDataEnvelope.RedactionValue + }; + await _service.PrepareMemberEmergencyContactWriteAsync(DeptId, contact, IssueGrant(), UserId, + workloadCaller: false); + contact.Name.Should().BeNull(); + } + + [Test] + public async Task A_neutralized_sentinel_reports_changed_so_the_row_is_re_persisted() + { + // Nothing reaches the broker, so Changed would otherwise stay false and the caller would + // leave the placeholder sitting in the database. + SetupWriteEnforced(); + SetupEncryptEcho(); + SetupCurrentCatalogVersion(); + + var note = new CallNote { CallNoteId = 8, Note = ProtectedDataEnvelope.RedactionValue }; + + var result = await _service.PrepareCallNoteWriteAsync(DeptId, note, IssueGrant(), UserId, + workloadCaller: false); + + result.Success.Should().BeTrue(); + result.Changed.Should().BeTrue(); + } + private void SetupCurrentCatalogVersion() { _dataProtectionService.Setup(x => x.GetPolicyByDepartmentIdAsync(DeptId, It.IsAny())) diff --git a/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs b/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs new file mode 100644 index 000000000..15987dc21 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/RemainingCandidateProtectionTests.cs @@ -0,0 +1,141 @@ +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Catalog v9 closes the plan's candidate list: unit log narratives, user state notes, calendar + /// entries, department documents, and the stored mailbox credentials from section 22.1. Nothing + /// the plan named as a protected-field candidate is left unbound after this. + /// + [TestFixture] + public class RemainingCandidateProtectionTests + { + private ProtectedFieldCatalog _catalog; + + [SetUp] + public void SetUp() => _catalog = new ProtectedFieldCatalog(); + + [Test] + public void The_catalog_is_at_version_nine_and_the_last_candidates_are_what_moved_it() + { + _catalog.Version.Should().Be(9); + + _catalog.GetAddedBetween(8, 9).Select(e => e.FieldId) + .Should().BeEquivalentTo(new[] + { + "unitlogs.narrative", + "userstates.note", + "calendaritems.title", + "calendaritems.description", + "calendaritems.location", + "documents.name", + "documents.description", + "documents.filename", + "documents.data", + "distributionlists.username", + "distributionlists.password" + }); + } + + /// + /// A unit log has no DepartmentId of its own, so it derives ownership through its unit — the + /// same shape UnitStates uses. Everything else in this wave carries its own. + /// + [Test] + public void Unit_logs_derive_their_department_through_the_unit() + { + var binding = AdpTableBindings.V1.Single(b => b.TableName == "UnitLogs"); + + binding.ParentTable.Should().Be("Units"); + binding.ParentFkColumn.Should().Be("UnitId"); + binding.DepartmentColumn.Should().BeNull("the row has no department column to scope by"); + } + + /// + /// A calendar has to lay out for a protected department without anyone stepping up: the + /// scheduling columns are structural and must never be encrypted. Only what a human wrote — + /// title, description, location — is protected. + /// + [Test] + public void Calendar_scheduling_columns_stay_plaintext() + { + var fieldIds = _catalog.GetAll().Select(e => e.FieldId).ToList(); + + foreach (var structural in new[] + { + "calendaritems.start", "calendaritems.end", "calendaritems.starttimezone", + "calendaritems.endtimezone", "calendaritems.recurrencerule", "calendaritems.recurrenceid" + }) + { + fieldIds.Should().NotContain(structural); + } + } + + [Test] + public void The_document_payload_is_bound_as_binary_with_its_name() + { + var columns = AdpTableBindings.V1.Single(b => b.TableName == "Documents").Columns; + + columns.Single(c => c.StorageKind == ProtectedFieldStorageKind.Binary) + .FieldId.Should().Be("documents.data"); + columns.Select(c => c.FieldId).Should().Contain("documents.filename", + "protecting the file while serving its name in the clear protects very little"); + } + + /// + /// Section 22.1 credential hygiene. Binding these is only safe because nothing in the + /// codebase reads them today — the broker's decrypt lane is grant-gated, so a future + /// grantless consumer would need an attended path or a separate secret store. This pins the + /// assumption so that a reader added later trips a test rather than a production mailbox. + /// + [Test] + public void Stored_mailbox_credentials_are_bound() + { + var columns = AdpTableBindings.V1.Single(b => b.TableName == "DistributionLists") + .Columns.Select(c => c.FieldId).ToList(); + + columns.Should().BeEquivalentTo(new[] { "distributionlists.username", "distributionlists.password" }); + + // The address and display name stay readable: they are how the list is administered and + // how inbound mail is routed to it. + _catalog.GetAll().Select(e => e.FieldId).Should().NotContain("distributionlists.emailaddress"); + _catalog.GetAll().Select(e => e.FieldId).Should().NotContain("distributionlists.name"); + } + + [Test] + public void A_catalog_upgrade_from_eight_touches_only_the_new_tables() + { + var scoped = AdpTableBindings.ForVersionRange(_catalog, 8, 9); + + scoped.Select(b => b.TableName).Should().BeEquivalentTo(new[] + { + "UnitLogs", "UserStates", "CalendarItems", "Documents", "DistributionLists" + }, "an upgrade sweep must not re-read a table it has nothing to do in"); + } + + /// + /// Every table the plan's field-and-storage audit prepared for protection is now bound. If a + /// new candidate is added to the plan, this is the test that should fail first. + /// + [Test] + public void No_prepared_candidate_table_is_left_unbound() + { + var bound = AdpTableBindings.V1.Select(b => b.TableName).ToList(); + + bound.Should().Contain(new[] + { + "Calls", "CallNotes", "CallAttachments", "CallLogs", "CallReferences", + "Contacts", "ContactNotes", "Logs", "UdfFieldValues", "UnitStates", + "DepartmentMemberSensitiveData", "DepartmentMemberEmergencyContacts", "PersonnelCertifications", + "Messages", "MessageRecipients", + "ModerationRequests", "ModerationReports", "ModerationActions", + "ChatMessageFlags", "ChatModerationActions", "ChatExports", + "UnitLogs", "UserStates", "CalendarItems", "Documents", "DistributionLists" + }); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/MessagesControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/MessagesControllerTests.cs index 5f1d1143d..018db7e4a 100644 --- a/Tests/Resgrid.Tests/Web/Services/MessagesControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/MessagesControllerTests.cs @@ -85,12 +85,30 @@ public void SetUp() _departmentGroupsService.Object, _personnelRolesService.Object, Mock.Of(), - Mock.Of()) + Mock.Of(), + PassThroughProtectedReads()) { ControllerContext = new ControllerContext { HttpContext = httpContext } }; } + /// + /// These tests exercise an unprotected department, where resolution is a no-op. A loose mock + /// would return a null Task from the resolve call and NRE at the await. + /// + private static IProtectedReadService PassThroughProtectedReads() + { + var stub = new Mock(); + stub.Setup(x => x.ResolveMessagesForReadAsync(It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProtectedReadResult()); + stub.Setup(x => x.ResolveMessageRecipientsForReadAsync(It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProtectedReadResult()); + + return stub.Object; + } + [TearDown] public void TearDown() { diff --git a/Tests/Resgrid.Tests/Web/Services/ModerationControllerTests.cs b/Tests/Resgrid.Tests/Web/Services/ModerationControllerTests.cs index be27790d6..ca0af46ba 100644 --- a/Tests/Resgrid.Tests/Web/Services/ModerationControllerTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/ModerationControllerTests.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Moq; +using System.Threading; using NUnit.Framework; using Resgrid.Model; using Resgrid.Model.Services; @@ -47,12 +48,33 @@ public void SetUp() ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; _activity = new Activity("ModerationControllerTests").Start(); - _controller = new ModerationController(_moderationService.Object, Mock.Of()) + _controller = new ModerationController(_moderationService.Object, Mock.Of(), + PassThroughProtectedReads()) { ControllerContext = new ControllerContext { HttpContext = httpContext } }; } + /// + /// These tests exercise an unprotected department, where resolution is a no-op. A loose mock + /// returns a null Task from the resolve call and NREs at the await. + /// + private static IProtectedReadService PassThroughProtectedReads() + { + var stub = new Mock(); + stub.Setup(x => x.ResolveModerationRequestsForReadAsync(It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProtectedReadResult()); + stub.Setup(x => x.ResolveModerationReportsForReadAsync(It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProtectedReadResult()); + stub.Setup(x => x.ResolveModerationActionsForReadAsync(It.IsAny(), It.IsAny>(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProtectedReadResult()); + + return stub.Object; + } + [TearDown] public void TearDown() { diff --git a/Tests/Resgrid.Tests/Web/User/ContactEditPersistenceTests.cs b/Tests/Resgrid.Tests/Web/User/ContactEditPersistenceTests.cs new file mode 100644 index 000000000..d855f0c70 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/ContactEditPersistenceTests.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using FluentAssertions; +using NUnit.Framework; + +namespace Resgrid.Tests.Web.User +{ + /// + /// The contact edit form binds 22 of the entity's columns; the entity has more — the image, the + /// geofence, the five government-ID fields, both address links and the audit stamps. The action + /// used to load the stored row, mutate it, and then persist the POSTED object, so every column + /// the form does not carry was blanked on every edit (for a protected department, that included + /// the only copy of enveloped ID numbers) and the address links it had just resolved were + /// thrown away. + /// + /// Structural, because the failure is invisible: the save succeeds and the redirect looks + /// normal. A behavioural test would need the whole controller graph to see it, and would not + /// notice a NEW field being copied onto the wrong object. + /// + [TestFixture] + public class ContactEditPersistenceTests + { + private static string EditPostBody() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) + directory = directory.Parent; + + directory.Should().NotBeNull("the tests must be able to find the repository root"); + + var path = Path.Combine(directory!.FullName, "Web", "Resgrid.Web", "Areas", "User", + "Controllers", "ContactsController.cs"); + + File.Exists(path).Should().BeTrue($"expected the controller at {path}"); + var source = File.ReadAllText(path); + + // The POST overload takes the view model; slice from its signature to the next method. + var start = source.IndexOf("public async Task Edit(EditContactView model", System.StringComparison.Ordinal); + start.Should().BeGreaterThan(0, "the Edit POST action should still exist"); + + var next = Regex.Match(source.Substring(start + 1), + @"(?:public|private|protected|internal)\s+(?:static\s+)?(?:async\s+)?Task<[^>]+>\s+\w+\s*\("); + + return next.Success ? source.Substring(start, next.Index + 1) : source.Substring(start); + } + + [Test] + public void The_edit_action_persists_the_stored_row_not_the_posted_one() + { + var body = EditPostBody(); + + body.Should().Contain("SaveContactAsync(contact,", + "the posted object carries only the columns this form binds; saving it blanks the rest"); + body.Should().NotContain("SaveContactAsync(model.Contact,"); + } + + [Test] + public void The_resolved_address_links_land_on_the_object_that_gets_saved() + { + var body = EditPostBody(); + + // Both links are resolved from the freshly saved Address rows; if either is assigned to + // the posted object while the stored row is persisted, editing an address silently + // fails to link it. + body.Should().Contain("contact.PhysicalAddressId = physicalAddress.AddressId;"); + body.Should().Contain("contact.MailingAddressId = mailingAddress.AddressId;"); + body.Should().NotContain("model.Contact.MailingAddressId = physicalAddress.AddressId;", + "the same-as-physical branch has to target the saved row too"); + } + + [Test] + public void An_edit_stamps_the_edit_fields_and_leaves_the_creator_alone() + { + var body = EditPostBody(); + + body.Should().Contain("contact.EditedByUserId = UserId;"); + body.Should().NotContain("AddedByUserId = UserId;", + "AddedOn/AddedByUserId belong to whoever created the contact; overwriting them on an edit loses that"); + } + + /// + /// Each pair of latitude/longitude inputs is labelled Location, Entrance or Exit in the + /// view. The POST used to write the LOCATION pair into EntranceGpsCoordinates (which the + /// entrance pair then overwrote) and the GET read Entrance back into the LOCATION inputs, so + /// LocationGpsCoordinates was never populated and the Entrance boxes were always empty. + /// + [Test] + public void Each_coordinate_pair_is_wired_to_the_column_its_label_names() + { + var body = EditPostBody(); + + body.Should().Contain("contact.LocationGpsCoordinates = ResolveCoordinates(model.LocationGpsLatitude"); + body.Should().Contain("contact.EntranceGpsCoordinates = ResolveCoordinates(model.EntranceGpsLatitude"); + body.Should().Contain("contact.ExitGpsCoordinates = ResolveCoordinates(model.ExitGpsLatitude"); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/User/ProtectedRevealAuthorizationTests.cs b/Tests/Resgrid.Tests/Web/User/ProtectedRevealAuthorizationTests.cs new file mode 100644 index 000000000..ad986795b --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/ProtectedRevealAuthorizationTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using FluentAssertions; +using NUnit.Framework; + +namespace Resgrid.Tests.Web.User +{ + /// + /// Every ADP reveal endpoint decrypts a record for a caller holding a Protected Data Grant. The + /// grant proves the CALLER stepped up with a second factor — it is not an authorization + /// decision about the TARGET, and treating it as one is exactly the hole found on + /// RevealCertifications in the PR #488 review, where any member with Profile_View could + /// decrypt another member's licence numbers. + /// + /// Structural rather than behavioural, for the same reason as + /// : the failure mode is a NEW Reveal endpoint + /// added for a new page without a subject check, which no test of the existing endpoints would + /// notice. So this discovers every "Reveal*" action across the User area and asserts each one + /// resolves and authorizes its subject. + /// + [TestFixture] + public class ProtectedRevealAuthorizationTests + { + private static readonly string[] Controllers = + { + "DispatchController.cs", + "ContactsController.cs", + "PersonnelController.cs", + "UnitsController.cs", + "HomeController.cs", + "ProfileController.cs", + "MessagesController.cs", + "DocumentsController.cs", + "CalendarController.cs" + }; + + /// + /// Calls that answer "may this caller reach THIS record", as opposed to a department + /// comparison, which only proves tenancy. Policy attributes are not enough on their own: + /// they say what the caller may do in general, never to whom. + /// + private static readonly string[] SubjectGuards = + { + "CanUserViewCallAsync", + "CanUserEditCallAsync", + "CanUserViewUserAsync", + "CanUserEditProfileAsync", + "CanUserModifyUnitAsync", + "CanUserViewUnitAsync", + "CanUserViewMessageAsync", + "CanReachCertificationsForAsync", + "GetAuthorizedCertificationAsync" + }; + + private static DirectoryInfo RepositoryRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) + directory = directory.Parent; + + directory.Should().NotBeNull("the tests must be able to find the repository root"); + return directory!; + } + + /// + /// Every method declaration is a boundary, not just the actions — otherwise a private helper + /// between two actions is absorbed into the one above it and its calls read as the action's. + /// + private static IEnumerable<(string Action, string Body)> MethodBodies(string source) + { + var matches = Regex.Matches(source, + @"(?:public|private|protected|internal)\s+(?:static\s+)?(?:async\s+)?Task<[^>]+>\s+(?\w+)\s*\(", + RegexOptions.Compiled).Cast().ToList(); + + for (var i = 0; i < matches.Count; i++) + { + var start = matches[i].Index; + var end = i + 1 < matches.Count ? matches[i + 1].Index : source.Length; + yield return (matches[i].Groups["name"].Value, source.Substring(start, end - start)); + } + } + + private static List<(string Controller, string Action, string Body)> RevealActions() + { + var root = RepositoryRoot(); + var found = new List<(string, string, string)>(); + + foreach (var controller in Controllers) + { + var path = Path.Combine(root.FullName, "Web", "Resgrid.Web", "Areas", "User", + "Controllers", controller); + + File.Exists(path).Should().BeTrue($"expected the controller at {path}"); + + foreach (var method in MethodBodies(File.ReadAllText(path))) + { + if (method.Action.StartsWith("Reveal", StringComparison.Ordinal)) + found.Add((controller, method.Action, method.Body)); + } + } + + return found; + } + + /// + /// Reveals of records that are department-level rather than personal. A contact belongs to + /// the department, not to a member: there is no per-record ACL to consult, so the resource + /// policy plus the department comparison IS the authorization model, and it is the same one + /// the page hosting the reveal uses. Listed explicitly so that a new endpoint cannot land + /// here by accident — adding one is a decision someone has to write down. + /// + private static readonly string[] DepartmentScopedReveals = + { + "ContactsController.cs:RevealContact", + + // A document and a calendar entry belong to the department, not to a member. There is no + // per-record ACL to consult beyond the resource policy and, for documents, the + // admins-only flag - which is the same model the pages hosting these reveals use. + "DocumentsController.cs:RevealDocument", + "CalendarController.cs:RevealCalendarItem" + }; + + [Test] + public void Every_reveal_endpoint_authorizes_its_subject() + { + var actions = RevealActions(); + + // If this trips, the discovery above stopped finding the endpoints (a rename, a move) — + // the assertions below would then pass vacuously. + actions.Should().HaveCountGreaterThanOrEqualTo(9, + "the User area hosts reveal endpoints for calls, contacts, personnel, units, the profile page and certifications"); + + foreach (var (controller, action, body) in actions) + { + if (DepartmentScopedReveals.Contains($"{controller}:{action}")) + { + body.Should().Contain("DepartmentId", + $"{controller}.{action} reveals a department-level record, so it must at least " + + "prove the record belongs to the caller's department"); + continue; + } + + SubjectGuards.Any(guard => body.Contains(guard)) + .Should().BeTrue($"{controller}.{action} must authorize the subject it reveals, " + + "not just validate the grant — a grant authorizes the caller, never the target"); + } + } + + [Test] + public void Every_reveal_endpoint_is_an_antiforgery_protected_post() + { + foreach (var (controller, action, body) in RevealActions()) + { + // The attributes sit above the signature, so they land at the END of the PREVIOUS + // method's slice; read the raw source around the declaration instead. + var root = RepositoryRoot(); + var source = File.ReadAllText(Path.Combine(root.FullName, "Web", "Resgrid.Web", "Areas", + "User", "Controllers", controller)); + + var index = source.IndexOf($"> {action}(", StringComparison.Ordinal); + index.Should().BeGreaterThan(0, $"{controller}.{action} should be declared in its own file"); + + var preamble = source.Substring(Math.Max(0, index - 400), Math.Min(400, index)); + + preamble.Should().Contain("[HttpPost]", + $"{controller}.{action} reveals decrypted values and must not be reachable by a GET " + + "(a URL lands in browser history, logs and referrers)"); + preamble.Should().Contain("[ValidateAntiForgeryToken]", + $"{controller}.{action} is a cookie-authenticated MVC endpoint"); + } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs index 50267425c..b3bf7f834 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CalendarController.cs @@ -39,17 +39,25 @@ public class CalendarController : V4AuthenticatedApiControllerbase private readonly IAuthorizationService _authorizationService; private readonly IEventAggregator _eventAggregator; private readonly IUserProfileService _userProfileService; + private readonly IProtectedReadService _protectedReadService; public CalendarController(ICalendarService calendarService, IDepartmentsService departmentsService, IAuthorizationService authorizationService, IEventAggregator eventAggregator, - IUserProfileService userProfileService) + IUserProfileService userProfileService, IProtectedReadService protectedReadService) { + _protectedReadService = protectedReadService; _calendarService = calendarService; _departmentsService = departmentsService; _authorizationService = authorizationService; _eventAggregator = eventAggregator; _userProfileService = userProfileService; } + /// + /// The caller's Protected Data Grant, if they presented one. Absent means calendar entries + /// resolve to the REDACTED placeholder rather than plaintext. + /// + private string ProtectedGrantToken => Request.Headers[DataProtectionController.GrantHeader].ToString(); + #endregion Members and Constructors /// @@ -65,6 +73,12 @@ public async Task> GetDepartmentCalendarI result.Data = new List(); var items = await _calendarService.GetAllCalendarItemsForDepartmentAsync(DepartmentId); + + // ADP (catalog v9): one broker batch for the page. With a grant the titles come back as + // plaintext; without one they are the REDACTED placeholder, never ciphertext. The + // scheduling columns are not cataloged, so the calendar lays out either way. + await _protectedReadService.ResolveCalendarItemsForReadAsync(DepartmentId, items, + ProtectedGrantToken, UserId); var types = await _calendarService.GetAllCalendarItemTypesForDepartmentAsync(DepartmentId); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var presonnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); @@ -115,6 +129,9 @@ public async Task> GetDepartmentCalendarI result.Data = new List(); var items = await _calendarService.GetAllCalendarItemsForDepartmentInRangeAsync(DepartmentId, start.SetToMidnight(), end.SetToEndOfDay()); + + await _protectedReadService.ResolveCalendarItemsForReadAsync(DepartmentId, items, + ProtectedGrantToken, UserId); var types = await _calendarService.GetAllCalendarItemTypesForDepartmentAsync(DepartmentId); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var presonnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); @@ -161,6 +178,10 @@ public async Task> GetCalendarItem(int id) { var result = new GetCalendarItemResult(); var item = await _calendarService.GetCalendarItemByIdAsync(id); + + if (item != null) + await _protectedReadService.ResolveCalendarItemsForReadAsync(DepartmentId, new[] { item }, + ProtectedGrantToken, UserId); var types = await _calendarService.GetAllCalendarItemTypesForDepartmentAsync(DepartmentId); var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var presonnelNames = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs index a6fab4612..5c4f8b479 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/MessagesController.cs @@ -42,6 +42,7 @@ public class MessagesController : V4AuthenticatedApiControllerbase private readonly IPersonnelRolesService _personnelRolesService; private readonly IUnitsService _unitsService; private readonly ICalendarService _calendarService; + private readonly IProtectedReadService _protectedReadService; public MessagesController( ICallsService callsService, @@ -54,7 +55,8 @@ public MessagesController( IDepartmentGroupsService departmentGroupsService, IPersonnelRolesService personnelRolesService, IUnitsService unitsService, - ICalendarService calendarService) + ICalendarService calendarService, + IProtectedReadService protectedReadService) { _callsService = callsService; _departmentsService = departmentsService; @@ -67,8 +69,16 @@ public MessagesController( _personnelRolesService = personnelRolesService; _unitsService = unitsService; _calendarService = calendarService; + _protectedReadService = protectedReadService; } + /// + /// The caller's Protected Data Grant, if they presented one. Held in app memory only and + /// minted by DataProtection/VerifyStepUp; absent means protected values resolve to the + /// REDACTED placeholder rather than plaintext. + /// + private string ProtectedGrantToken => Request.Headers[DataProtectionController.GrantHeader].ToString(); + #endregion Members and Constructors /// @@ -84,6 +94,13 @@ public async Task> GetInboxMessages([FromHeader( var result = new GetMessagesResult(); var messages = (await _messageService.GetInboxMessagesByUserIdAsync(UserId)).OrderByDescending(x => x.SentOn); + + // ADP (catalog v7): one broker batch for the whole page. With a valid grant the subjects + // and bodies come back as plaintext; without one they come back as the REDACTED + // placeholder - never as ciphertext. + await _protectedReadService.ResolveMessagesForReadAsync(DepartmentId, messages.ToList(), + ProtectedGrantToken, UserId); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); @@ -131,6 +148,12 @@ public async Task> GetOutboxMessages([FromHeader var result = new GetMessagesResult(); var messages = (await _messageService.GetSentMessagesByUserIdAsync(UserId)).OrderBy(x => x.SentOn) .OrderByDescending(x => x.SentOn); + + // ADP (catalog v7): one broker batch for the page. Sending a message does not exempt the + // sender - the row is encrypted under the department key, not theirs. + await _protectedReadService.ResolveMessagesForReadAsync(DepartmentId, messages.ToList(), + ProtectedGrantToken, UserId); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); var names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); @@ -188,6 +211,11 @@ public async Task> GetMessage(int messageId, Canc await _messageService.ReadMessageRecipientAsync(messageId, UserId, cancellationToken); + // ADP (catalog v7). Resolved AFTER the read-receipt write so the receipt is not + // carrying decrypted values back into the row. + await _protectedReadService.ResolveMessagesForReadAsync(DepartmentId, + new List { savedMessage }, ProtectedGrantToken, UserId); + result.Data = ConvertMessageResultData(savedMessage, department, UserId, names); result.PageSize = 1; result.Status = ResponseHelper.Success; @@ -454,7 +482,7 @@ public async Task> RespondToMessage([FromBo { if ((responseInput.Type != CalendarRsvpResponseTypeAttending && responseInput.Type != CalendarRsvpResponseTypeNotAttending) - || !TextResponsePromptMetadata.TryGetCalendarItemId(response.Note, out var calendarItemId)) + || !TextResponsePromptMetadata.TryGetCalendarItemId(response.PromptMetadata, out var calendarItemId)) return BadRequest(); var calendarItem = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); @@ -594,7 +622,7 @@ public static MessageResultData ConvertMessageResultData(Message savedMessage, D message.RespondedOn = respose.ReadOn; if (savedMessage.Type == (int)MessageTypes.CalendarRsvp - && TextResponsePromptMetadata.TryGetCalendarItemId(respose.Note, out var calendarItemId)) + && TextResponsePromptMetadata.TryGetCalendarItemId(respose.PromptMetadata, out var calendarItemId)) message.CalendarItemId = calendarItemId.ToString(); } else diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs index 5a3f007f6..0b5aecedb 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ModerationController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -20,11 +21,47 @@ public class ModerationController : V4AuthenticatedApiControllerbase { private readonly IModerationService _moderationService; private readonly IAuthorizationService _authorizationService; + private readonly IProtectedReadService _protectedReadService; - public ModerationController(IModerationService moderationService, IAuthorizationService authorizationService) + public ModerationController(IModerationService moderationService, IAuthorizationService authorizationService, + IProtectedReadService protectedReadService) { _moderationService = moderationService; _authorizationService = authorizationService; + _protectedReadService = protectedReadService; + } + + /// + /// The caller's Protected Data Grant, if they presented one. A moderator needs their normal + /// permission AND a current grant to see the excerpts (plan 5.3); without one the queue is + /// still usable - status, reason code and counts are structural and never encrypted - but + /// every quoted value reads as the REDACTED placeholder rather than ciphertext. + /// + private string ProtectedGrantToken => Request.Headers[DataProtectionController.GrantHeader].ToString(); + + /// + /// Resolves a moderation request and its children in one broker batch. The reported FILE is + /// never included here: a list or detail payload carries metadata, and the endpoints that + /// actually serve bytes opt in separately. + /// + private async Task ResolveModerationAsync(IReadOnlyList requests, + CancellationToken cancellationToken = default) + { + if (requests == null || requests.Count == 0) + return; + + await _protectedReadService.ResolveModerationRequestsForReadAsync(DepartmentId, requests, + ProtectedGrantToken, UserId, includeContent: false, cancellationToken); + + var reports = requests.Where(r => r?.Reports != null).SelectMany(r => r.Reports).ToList(); + if (reports.Count > 0) + await _protectedReadService.ResolveModerationReportsForReadAsync(DepartmentId, reports, + ProtectedGrantToken, UserId, cancellationToken); + + var actions = requests.Where(r => r?.Actions != null).SelectMany(r => r.Actions).ToList(); + if (actions.Count > 0) + await _protectedReadService.ResolveModerationActionsForReadAsync(DepartmentId, actions, + ProtectedGrantToken, UserId, includeContent: false, cancellationToken); } /// Reports an accessible chat message, Message, call note or call image. @@ -80,6 +117,10 @@ public async Task> GetMyStatus(int item if (request == null) return NotFound(); + // The reporter sees their own flag's status; the quoted content follows the same rule as + // the moderator queue - revealed with a grant, placeholder without one. + await ResolveModerationAsync(new[] { request }); + var result = new GetModerationRequestResult { Data = ConvertRequest(request, false), @@ -116,6 +157,8 @@ public async Task> GetRequests(int? st PageSize = pageSize }); + await ResolveModerationAsync(requests); + var result = new GetModerationRequestsResult { Data = requests.Select(x => ConvertRequest(x, true)).ToList(), @@ -138,6 +181,8 @@ public async Task> GetRequest(string re if (request == null) return NotFound(); + await ResolveModerationAsync(new[] { request }); + var result = new GetModerationRequestResult { Data = ConvertRequest(request, true), diff --git a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStaffingController.cs b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStaffingController.cs index e1c86f95c..c19be43d8 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStaffingController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/PersonnelStaffingController.cs @@ -32,11 +32,18 @@ public class PersonnelStaffingController : V4AuthenticatedApiControllerbase private readonly IDepartmentsService _departmentsService; private readonly IUserProfileService _userProfileService; private readonly IUserStateService _userStateService; + private readonly IProtectedReadService _protectedReadService; private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IPersonnelRolesService _personnelRolesService; private readonly IDepartmentSettingsService _departmentSettingsService; private readonly Model.Services.IAuthorizationService _authorizationService; + /// + /// The caller's Protected Data Grant, if they presented one. Absent means a staffing note + /// resolves to the REDACTED placeholder rather than plaintext. + /// + private string ProtectedGrantToken => Request.Headers[DataProtectionController.GrantHeader].ToString(); + public PersonnelStaffingController( IUsersService usersService, IActionLogsService actionLogsService, @@ -46,9 +53,11 @@ public PersonnelStaffingController( IDepartmentGroupsService departmentGroupsService, IPersonnelRolesService personnelRolesService, IDepartmentSettingsService departmentSettingsService, - Model.Services.IAuthorizationService authorizationService + Model.Services.IAuthorizationService authorizationService, + IProtectedReadService protectedReadService ) { + _protectedReadService = protectedReadService; _usersService = usersService; _actionLogsService = actionLogsService; _departmentsService = departmentsService; @@ -91,6 +100,13 @@ public async Task> GetCurrentStatffing(st } var userState = await _userStateService.GetLastUserStateByUserIdAsync(userId); + + // ADP (catalog v9): a staffing note is often why someone is unavailable. With a grant it + // comes back as plaintext; without one as the REDACTED placeholder, never ciphertext. + if (userState != null) + await _protectedReadService.ResolveUserStatesForReadAsync(DepartmentId, new[] { userState }, + ProtectedGrantToken, UserId); + var department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); if (userState != null) diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 4eb21c92d..f42704763 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -111,6 +111,12 @@ Mobile or Tablet Device specific operations + + + The caller's Protected Data Grant, if they presented one. Absent means calendar entries + resolve to the REDACTED placeholder rather than plaintext. + + Gets the department calendar items. @@ -2106,6 +2112,13 @@ Messaging system interaction + + + The caller's Protected Data Grant, if they presented one. Held in app memory only and + minted by DataProtection/VerifyStepUp; absent means protected values resolve to the + REDACTED placeholder rather than plaintext. + + Returns all inbox messages for a user. @@ -2158,6 +2171,21 @@ Department and group-scoped moderation requests across supported content types. + + + The caller's Protected Data Grant, if they presented one. A moderator needs their normal + permission AND a current grant to see the excerpts (plan 5.3); without one the queue is + still usable - status, reason code and counts are structural and never encrypted - but + every quoted value reads as the REDACTED placeholder rather than ciphertext. + + + + + Resolves a moderation request and its children in one broker batch. The reported FILE is + never included here: a list or detail payload carries metadata, and the endpoints that + actually serve bytes opt in separately. + + Reports an accessible chat message, Message, call note or call image. @@ -2291,6 +2319,12 @@ Operations to perform against personnel staffing, i.e. Available, Delayed, in a department + + + The caller's Protected Data Grant, if they presented one. Absent means a staffing note + resolves to the REDACTED placeholder rather than plaintext. + + Gets the current staffing for a user diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ModerationRequestsTable.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ModerationRequestsTable.tsx index 963456672..20cb048b4 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ModerationRequestsTable.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/moderation/ModerationRequestsTable.tsx @@ -119,6 +119,15 @@ export default function ModerationRequestsTable({ reportMode = false }: Moderati useEffect(() => { void load(); }, [load]); + // ADP: revealing (or concealing) changes what the SAME request returns - the excerpts come back + // as plaintext or as the REDACTED placeholder - so the queue re-reads rather than trying to + // patch rows in place. + useEffect(() => { + const reload = () => { void load(); }; + window.addEventListener('resgrid:adp-reveal-changed', reload); + return () => window.removeEventListener('resgrid:adp-reveal-changed', reload); + }, [load]); + const complete = async (request: ModerationRequestDto, disposition: 1 | 2) => { setBusy(request.ModerationRequestId); setError(null); diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/runtime/api.ts b/Web/Resgrid.Web/Areas/User/Apps/src/runtime/api.ts index 047ab6174..73561e595 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/runtime/api.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/runtime/api.ts @@ -28,6 +28,15 @@ export function buildApiUrl(path: string, query?: ApiQuery): string { export function apiAuthHeaders(extra?: HeadersInit): Headers { const headers = new Headers(extra); headers.set('Accept', 'application/json'); + + // ADP (plan 7.2): when the page has an active Protected Data Grant, every API read carries it, + // so the server returns plaintext instead of the REDACTED placeholder. The token lives in the + // reveal module's closure and is written straight into these headers - it is never handed back + // to this code, and a page without the module simply sends nothing. + const reveal = (window as unknown as { resgridAdpReveal?: { applyGrantHeader?: (target: Headers) => void } }) + .resgridAdpReveal; + reveal?.applyGrantHeader?.(headers); + return headers; } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs index 193b8881b..feb2ceaf5 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CalendarController.cs @@ -29,6 +29,7 @@ public class CalendarController : SecureBaseController private readonly IDepartmentsService _departmentsService; private readonly IUsersService _usersService; private readonly ICalendarService _calendarService; + private readonly IProtectedReadService _protectedReadService; private readonly IDepartmentGroupsService _departmentGroupsService; private readonly IGeoLocationProvider _geoLocationProvider; private readonly IEventAggregator _eventAggregator; @@ -40,8 +41,10 @@ public class CalendarController : SecureBaseController public CalendarController(IDepartmentsService departmentsService, IUsersService usersService, ICalendarService calendarService, IDepartmentGroupsService departmentGroupsService, IGeoLocationProvider geoLocationProvider, IEventAggregator eventAggregator, IAuthorizationService authorizationService, IUserProfileService userProfileService, - IPermissionsService permissionsService, IPersonnelRolesService personnelRolesService) + IPermissionsService permissionsService, IPersonnelRolesService personnelRolesService, + IProtectedReadService protectedReadService) { + _protectedReadService = protectedReadService; _departmentsService = departmentsService; _usersService = usersService; _calendarService = calendarService; @@ -74,6 +77,10 @@ public async Task Index() model.UpcomingItems = new List(); model.UpcomingItems = await _calendarService.GetUpcomingCalendarItemsAsync(DepartmentId, DateTime.UtcNow); + // ADP (catalog v9): titles, descriptions and locations render as REDACTED on the server. + // The scheduling columns are never encrypted, so the calendar still lays out. + await _protectedReadService.ResolveCalendarItemsForReadAsync(DepartmentId, model.UpcomingItems, null, UserId); + // Check calendar sync permission var calSyncPermission = await _permissionsService.GetPermissionByDepartmentTypeAsync(DepartmentId, PermissionTypes.UseCalendarSync); var department = model.Department; @@ -185,6 +192,33 @@ public async Task New(NewCalendarEntry model, CancellationToken c return View(model); } + /// + /// ADP client-side reveal (plan 7.2) for a calendar entry's title, description and location. + /// The subject is authorized exactly as the page hosting the reveal authorizes it. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Schedule_View)] + public async Task RevealCalendarItem([FromForm] int calendarItemId) + { + var item = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); + + if (item == null || item.DepartmentId != DepartmentId) + return NotFound(); + + string grantToken = Request.Headers["X-Resgrid-Protected-Grant"]; + var resolved = await _protectedReadService.ResolveCalendarItemsForReadAsync(DepartmentId, + new[] { item }, grantToken, UserId); + + if (resolved.IsProtected && resolved.ProtectedReason != null) + return Json(new { success = false, error = resolved.ProtectedReason }); + + var fields = Resgrid.Services.ProtectedReadService.CalendarItemFieldAccessors + .ToDictionary(a => a.Key, a => a.Value.Get(item)); + + return Json(new { success = true, fields }); + } + [HttpGet] [Authorize(Policy = ResgridResources.Schedule_Update)] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] @@ -196,6 +230,12 @@ public async Task Edit(int id) return Unauthorized(); model.Item = await _calendarService.GetCalendarItemByIdAsync(id); + + // ADP: the edit form renders protected values as the REDACTED sentinel; a field posted + // back unchanged is restored to its stored envelope by the write safety net. + var protectedRead = await _protectedReadService.ResolveCalendarItemsForReadAsync(DepartmentId, + new[] { model.Item }, null, UserId); + model.IsProtectedItem = protectedRead.IsProtected; model.Types = new List(); model.Types.Add(new CalendarItemType() { CalendarItemTypeId = 0, Name = "No Type" }); model.Types.AddRange(await _calendarService.GetAllCalendarItemTypesForDepartmentAsync(DepartmentId)); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs index c2db98f74..1e9809f92 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs @@ -308,9 +308,12 @@ public async Task Add(AddContactView model, CancellationToken can auditEvent.ServerName = Environment.MachineName; auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + // Each pair of inputs writes the column its label names. The location pair used to + // write EntranceGpsCoordinates, which the entrance pair then overwrote, so + // LocationGpsCoordinates was never populated from this form. if (!String.IsNullOrWhiteSpace(model.LocationGpsLatitude) && !String.IsNullOrWhiteSpace(model.LocationGpsLongitude)) { - model.Contact.EntranceGpsCoordinates = $"{model.LocationGpsLatitude},{model.LocationGpsLongitude}"; + model.Contact.LocationGpsCoordinates = $"{model.LocationGpsLatitude},{model.LocationGpsLongitude}"; } if (!String.IsNullOrWhiteSpace(model.EntranceGpsLatitude) && !String.IsNullOrWhiteSpace(model.EntranceGpsLongitude)) @@ -413,17 +416,14 @@ public async Task Edit(string contactId) // ADP: the edit form renders protected values as the REDACTED sentinel; unchanged // fields posted back are restored to their stored envelopes by the write safety net. - await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, + // Editing blind is workable but poor, so the page also carries the reveal banner. + var protectedEditRead = await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, new List { model.Contact }, null, UserId); + model.IsProtectedContact = protectedEditRead.IsProtected; - if (!String.IsNullOrWhiteSpace(model.Contact.EntranceGpsCoordinates) && - model.Contact.EntranceGpsCoordinates.Contains(',')) - { - var entranceGpsCoordinates = model.Contact.EntranceGpsCoordinates.Split(','); - model.LocationGpsLatitude = entranceGpsCoordinates[0]; - model.LocationGpsLongitude = entranceGpsCoordinates[1]; - } - + // Each pair of inputs reads the column its label names. The Entrance coordinates used + // to be read into the LOCATION inputs (and the location block then overwrote them), so + // the Entrance boxes on this form were never populated at all. if (!String.IsNullOrWhiteSpace(model.Contact.LocationGpsCoordinates) && model.Contact.LocationGpsCoordinates.Contains(',')) { @@ -432,6 +432,14 @@ await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, model.LocationGpsLongitude = locationGpsCoordinates[1]; } + if (!String.IsNullOrWhiteSpace(model.Contact.EntranceGpsCoordinates) && + model.Contact.EntranceGpsCoordinates.Contains(',')) + { + var entranceGpsCoordinates = model.Contact.EntranceGpsCoordinates.Split(','); + model.EntranceGpsLatitude = entranceGpsCoordinates[0]; + model.EntranceGpsLongitude = entranceGpsCoordinates[1]; + } + if (!String.IsNullOrWhiteSpace(model.Contact.ExitGpsCoordinates) && model.Contact.ExitGpsCoordinates.Contains(',')) { @@ -583,32 +591,57 @@ public async Task Edit(EditContactView model, CancellationToken c auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; auditEvent.Before = contact.CloneJsonToString(); - if (!String.IsNullOrWhiteSpace(model.LocationGpsLatitude) && !String.IsNullOrWhiteSpace(model.LocationGpsLongitude)) - { - contact.EntranceGpsCoordinates = $"{model.LocationGpsLatitude},{model.LocationGpsLongitude}"; - } - else + // The STORED row is the save target, not the posted one. This form binds 22 of the + // contact's columns; the entity has more - the image, the geofence, the five + // government-ID fields, both address links and the audit stamps - and persisting the + // posted object blanked every one of them on every edit. For a protected department + // that included the only copy of enveloped ID numbers. Copying the posted values + // onto the stored row is also what the rest of this area does (UnitsController.EditUnit). + contact.ContactType = model.Contact.ContactType; + contact.ContactCategoryId = model.Contact.ContactCategoryId; + contact.FirstName = model.Contact.FirstName; + contact.MiddleName = model.Contact.MiddleName; + contact.LastName = model.Contact.LastName; + contact.OtherName = model.Contact.OtherName; + contact.CompanyName = model.Contact.CompanyName; + contact.Email = model.Contact.Email; + contact.HomePhoneNumber = model.Contact.HomePhoneNumber; + contact.CellPhoneNumber = model.Contact.CellPhoneNumber; + contact.FaxPhoneNumber = model.Contact.FaxPhoneNumber; + contact.OfficePhoneNumber = model.Contact.OfficePhoneNumber; + contact.Description = model.Contact.Description; + contact.OtherInfo = model.Contact.OtherInfo; + contact.Website = model.Contact.Website; + contact.Twitter = model.Contact.Twitter; + contact.Facebook = model.Contact.Facebook; + contact.LinkedIn = model.Contact.LinkedIn; + contact.Instagram = model.Contact.Instagram; + contact.Threads = model.Contact.Threads; + contact.Bluesky = model.Contact.Bluesky; + contact.Mastodon = model.Contact.Mastodon; + + // ADP: a protected contact renders its coordinates as the REDACTED placeholder, which + // has no comma to split, so the latitude/longitude inputs come back EMPTY for a value + // the editor was never shown. Clearing on empty would destroy the stored coordinates + // on ANY save of this page. The form round-trips the placeholder in a hidden field, + // so "empty inputs + placeholder" means unchanged, while empty inputs with no + // placeholder stay a deliberate clear. + string ResolveCoordinates(string latitude, string longitude, string postedValue, string storedValue) { - contact.EntranceGpsCoordinates = null; - } + if (!String.IsNullOrWhiteSpace(latitude) && !String.IsNullOrWhiteSpace(longitude)) + return $"{latitude},{longitude}"; - if (!String.IsNullOrWhiteSpace(model.EntranceGpsLatitude) && !String.IsNullOrWhiteSpace(model.EntranceGpsLongitude)) - { - contact.EntranceGpsCoordinates = $"{model.EntranceGpsLatitude},{model.EntranceGpsLongitude}"; - } - else - { - contact.EntranceGpsCoordinates = null; + return postedValue == ProtectedDataEnvelope.RedactionValue ? storedValue : null; } - if (!String.IsNullOrWhiteSpace(model.ExitGpsLatitude) && !String.IsNullOrWhiteSpace(model.ExitGpsLongitude)) - { - contact.ExitGpsCoordinates = $"{model.ExitGpsLatitude},{model.ExitGpsLongitude}"; - } - else - { - contact.ExitGpsCoordinates = null; - } + // Each pair of inputs writes the column its label names; the location pair used to + // write EntranceGpsCoordinates, which the entrance pair then overwrote. + contact.LocationGpsCoordinates = ResolveCoordinates(model.LocationGpsLatitude, model.LocationGpsLongitude, + model.Contact.LocationGpsCoordinates, contact.LocationGpsCoordinates); + contact.EntranceGpsCoordinates = ResolveCoordinates(model.EntranceGpsLatitude, model.EntranceGpsLongitude, + model.Contact.EntranceGpsCoordinates, contact.EntranceGpsCoordinates); + contact.ExitGpsCoordinates = ResolveCoordinates(model.ExitGpsLatitude, model.ExitGpsLongitude, + model.Contact.ExitGpsCoordinates, contact.ExitGpsCoordinates); if (!String.IsNullOrWhiteSpace(model.PhysicalAddress1)) { @@ -627,7 +660,7 @@ public async Task Edit(EditContactView model, CancellationToken c contact.PhysicalAddressId = physicalAddress.AddressId; if (model.MailingAddressSameAsPhysical) - model.Contact.MailingAddressId = physicalAddress.AddressId; + contact.MailingAddressId = physicalAddress.AddressId; } if (!String.IsNullOrWhiteSpace(model.MailingAddress1) && !model.MailingAddressSameAsPhysical) @@ -647,11 +680,14 @@ public async Task Edit(EditContactView model, CancellationToken c contact.MailingAddressId = mailingAddress.AddressId; } - model.Contact.DepartmentId = DepartmentId; - model.Contact.AddedByUserId = UserId; - model.Contact.AddedOn = DateTime.UtcNow; + // AddedOn/AddedByUserId belong to whoever created the contact; an edit stamps the + // edit fields instead. They used to be overwritten with the editing user and now, + // which lost the creator on the first edit. + contact.DepartmentId = DepartmentId; + contact.EditedByUserId = UserId; + contact.EditedOn = DateTime.UtcNow; - await _contactsService.SaveContactAsync(model.Contact, cancellationToken); + await _contactsService.SaveContactAsync(contact, cancellationToken); // Save UDF field values for the updated contact var udfDefinitionForEdit = await _userDefinedFieldsService.GetActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Contact); @@ -682,7 +718,8 @@ public async Task Edit(EditContactView model, CancellationToken c } } - auditEvent.After = model.Contact.CloneJsonToString(); + // The saved row, not the posted one - "after" has to describe what was persisted. + auditEvent.After = contact.CloneJsonToString(); _eventAggregator.SendMessage(auditEvent); return RedirectToAction("Index", "Contacts", new { Area = "User" }); @@ -946,6 +983,12 @@ public async Task RevealContact([FromForm] string contactId) var fields = Resgrid.Services.ProtectedReadService.ContactFieldAccessors .ToDictionary(a => a.Key, a => a.Value.Get(contact)); + // The contact's UDF values are cataloged too, and the form renders them as inputs marked + // for this module. Revealing the contact but leaving its custom fields showing the + // placeholder would be an odd half-reveal of the same record. + await ProtectedUdfRevealHelper.AddUdfValuesAsync(fields, _userDefinedFieldsService, + _protectedReadService, DepartmentId, UdfEntityType.Contact, contactId, grantToken, UserId); + return Json(new { success = true, fields }); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs index 5a35e25ef..ad8efb25f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DataProtectionController.cs @@ -139,6 +139,20 @@ public async Task SizingScan(int windowMinutes, CancellationToken return Json(result); } + /// + /// Row-count progress for the status panel while a migration is in flight. Value-free: row + /// counts and table names, never a value out of any row. Read-only, so it stays a GET and + /// is polled by the status panel. + /// + [HttpGet] + public async Task MigrationProgress(CancellationToken cancellationToken) + { + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + return Json(await _dataProtectionService.GetMigrationProgressAsync(DepartmentId, cancellationToken)); + } + /// /// 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 diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index bdbe20c2c..e56e1ef5c 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -753,8 +753,11 @@ public async Task UpdateCall(int callId) model.Call = await _callsService.PopulateCallData(model.Call, true, true, true, true, true, true, true, true, true); // ADP: the edit form renders protected values as the REDACTED sentinel; a field posted - // back unchanged is restored to its stored envelope by the write safety net. - model.Call = (await _protectedReadService.ResolveForReadAsync(DepartmentId, model.Call, null, UserId)).Call; + // back unchanged is restored to its stored envelope by the write safety net. Editing + // blind is workable but poor, so the page also carries the reveal banner. + var protectedEditRead = await _protectedReadService.ResolveForReadAsync(DepartmentId, model.Call, null, UserId); + model.Call = protectedEditRead.Call; + model.IsProtectedCall = protectedEditRead.IsProtected; model.CallPriority = model.Call.Priority; model = await FillUpdateCallView(model); @@ -1391,6 +1394,12 @@ public async Task RevealCall([FromForm] int callId) var fields = Resgrid.Services.ProtectedReadService.CallFieldAccessors .ToDictionary(a => a.Key, a => a.Value.Get(resolved.Call)); + // The call's user-defined fields are cataloged too, and both the view and the edit form + // mark them for this module. Revealing the call while its custom fields keep showing the + // placeholder is a half-reveal of one record. + await ProtectedUdfRevealHelper.AddUdfValuesAsync(fields, _userDefinedFieldsService, + _protectedReadService, DepartmentId, UdfEntityType.Call, callId.ToString(), grantToken, UserId); + return Json(new { success = true, fields }); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DocumentsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DocumentsController.cs index 86c1126b3..9a6bcde7f 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DocumentsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DocumentsController.cs @@ -25,10 +25,13 @@ public class DocumentsController : SecureBaseController #region Private Members and Constructors private readonly IDepartmentsService _departmentsService; private readonly IDocumentsService _documentsService; + private readonly IProtectedReadService _protectedReadService; private readonly IEventAggregator _eventAggregator; - public DocumentsController(IDepartmentsService departmentsService, IDocumentsService documentsService, IEventAggregator eventAggregator) + public DocumentsController(IDepartmentsService departmentsService, IDocumentsService documentsService, + IEventAggregator eventAggregator, IProtectedReadService protectedReadService) { + _protectedReadService = protectedReadService; _departmentsService = departmentsService; _documentsService = documentsService; _eventAggregator = eventAggregator; @@ -42,6 +45,11 @@ public async Task Index(string type, string category) model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); model.Documents = await _documentsService.GetFilteredDocumentsByDepartmentIdAsync(DepartmentId, type, category); + // ADP (catalog v9): server-rendered pages always render protected values as REDACTED - a + // grant lives only in the browser. The file bytes are stripped here as well, so a listing + // can never carry ciphertext. + await _protectedReadService.ResolveDocumentsForReadAsync(DepartmentId, model.Documents, null, UserId); + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) model.Documents = model.Documents.Where(x => !x.AdminsOnly).ToList(); @@ -83,9 +91,14 @@ public async Task ViewDocument(int documentId) if (document.AdminsOnly && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) return Unauthorized(); + // ADP: REDACTED on the server; the reveal is client-side (step-up modal then RevealDocument). + var protectedRead = await _protectedReadService.ResolveDocumentsForReadAsync(DepartmentId, + new[] { document }, null, UserId); + var canManageDocument = ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || document.UserId == UserId; var model = new ViewDocumentView { + IsProtectedDocument = protectedRead.IsProtected, Document = document, Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false), UploadedByName = await UserHelper.GetFullNameForUser(document.UserId), @@ -97,6 +110,40 @@ public async Task ViewDocument(int documentId) return View(model); } + /// + /// ADP client-side reveal (plan 7.2). A grant proves the CALLER stepped up; it is never an + /// authorization decision about the target, so the document is authorized exactly as the page + /// hosting the reveal authorizes it - including the admins-only flag. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Documents_View)] + public async Task RevealDocument([FromForm] int documentId) + { + var document = await _documentsService.GetDocumentByIdAsync(documentId); + + if (document == null || document.DepartmentId != DepartmentId) + return NotFound(); + + if (document.AdminsOnly && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + string grantToken = Request.Headers["X-Resgrid-Protected-Grant"]; + + // Metadata only: the file itself is fetched separately through the reveal module's + // download helper, which carries the grant on that request. + var resolved = await _protectedReadService.ResolveDocumentsForReadAsync(DepartmentId, + new[] { document }, grantToken, UserId); + + if (resolved.IsProtected && resolved.ProtectedReason != null) + return Json(new { success = false, error = resolved.ProtectedReason }); + + var fields = Resgrid.Services.ProtectedReadService.DocumentFieldAccessors + .ToDictionary(a => a.Key, a => a.Value.Get(document)); + + return Json(new { success = true, fields }); + } + [HttpGet] [Authorize(Policy = ResgridResources.Documents_View)] public async Task GetDepartmentDocumentCategories() @@ -119,6 +166,19 @@ public async Task GetDocument(int documentId) if (document.AdminsOnly && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) return Unauthorized(); + // ADP: the file decrypts only for a caller presenting a valid grant. A plain + // cannot carry the header, so the view downloads through resgridAdpReveal.download(). + var protectedRead = await _protectedReadService.ResolveDocumentsForReadAsync(DepartmentId, + new[] { document }, Request.Headers["X-Resgrid-Protected-Grant"], UserId, includeData: true); + + if (document.Data == null || ProtectedReadService.IsBinaryEnveloped(document.Data)) + { + // Either the payload could not be decrypted or it is still enveloped. Ciphertext is + // never served as a file; the caller is told nothing beyond "not available". + Logging.LogInfo($"Protected document {document.DocumentId} was not served ({protectedRead.ProtectedReason ?? "enveloped"})."); + return NotFound(); + } + return new FileContentResult(document.Data, String.IsNullOrWhiteSpace(document.Type) ? "application/octet-stream" : document.Type) { FileDownloadName = String.IsNullOrWhiteSpace(document.Filename) ? document.Name : document.Filename diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index 0f1df69cb..05f489352 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -463,11 +463,15 @@ public async Task EditUserProfile(string userId) await _protectedReadService.ResolveMemberSensitiveDataForReadAsync(DepartmentId, new[] { memberAddresses }, Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); - // The legacy shared-Addresses link is read ONLY while the department is unprotected and - // relocation has not reached this member yet. Once protection is enforced that link is a - // plaintext copy of data this department has already encrypted, and rendering it would - // walk straight around the reveal pipeline. - var legacyAddressFallbackAllowed = !await _dataProtectionService.IsProtectionEnforcedAsync(DepartmentId); + // M0141 (contract) cleared the legacy shared-Addresses links and deleted the rows nothing + // else referenced, so there is no fallback left to read: the department-scoped copy is + // the only copy. The protection state is still needed for the reveal banner below. + var protectionEnforced = await _dataProtectionService.IsProtectionEnforcedAsync(DepartmentId); + + // When protection is enforced, this page is showing placeholders for the identification + // number, the addresses, the emergency contacts and the custom fields, and a step-up can + // open them. + model.IsProtectedProfile = protectionEnforced; if (memberAddresses != null && !string.IsNullOrWhiteSpace(memberAddresses.HomeAddress1)) { @@ -477,15 +481,6 @@ await _protectedReadService.ResolveMemberSensitiveDataForReadAsync(DepartmentId, model.PhysicalPostalCode = memberAddresses.HomePostalCode; model.PhysicalState = memberAddresses.HomeState; } - else if (legacyAddressFallbackAllowed && model.Profile != null && model.Profile.HomeAddressId.HasValue) - { - var homeAddress = await _addressService.GetAddressByIdAsync(model.Profile.HomeAddressId.Value); - model.PhysicalAddress1 = homeAddress.Address1; - model.PhysicalCity = homeAddress.City; - model.PhysicalCountry = homeAddress.Country; - model.PhysicalPostalCode = homeAddress.PostalCode; - model.PhysicalState = homeAddress.State; - } if (memberAddresses != null && !string.IsNullOrWhiteSpace(memberAddresses.MailingAddress1)) { @@ -518,23 +513,6 @@ bool SameComponent(string mailing, string home) => SameComponent(memberAddresses.MailingPostalCode, memberAddresses.HomePostalCode) && SameComponent(memberAddresses.MailingCountry, memberAddresses.HomeCountry); } - else if (legacyAddressFallbackAllowed && model.Profile != null && model.Profile.MailingAddressId.HasValue) - { - if (model.Profile.HomeAddressId.HasValue && - model.Profile.MailingAddressId.Value == model.Profile.HomeAddressId.Value) - { - model.MailingAddressSameAsPhysical = true; - } - else - { - var mailingAddress = await _addressService.GetAddressByIdAsync(model.Profile.MailingAddressId.Value); - model.MailingAddress1 = mailingAddress.Address1; - model.MailingCity = mailingAddress.City; - model.MailingCountry = mailingAddress.Country; - model.MailingPostalCode = mailingAddress.PostalCode; - model.MailingState = mailingAddress.State; - } - } if (model.Profile != null) model.Carrier = (MobileCarriers)model.Profile.MobileCarrier; @@ -1380,6 +1358,52 @@ public async Task GetDataExportStatus() #endregion GDPR Data Export + /// + /// ADP client-side reveal (plan 7.2) for the profile page: the member's department-scoped + /// sensitive data (identification number, home and mailing address) and their custom field + /// values. Emergency contacts are NOT included — they are rendered by their own module from + /// GetEmergencyContacts, which already reads this same grant header, so the page re-fetches + /// that list when a reveal succeeds. + /// + /// A grant proves the CALLER stepped up; it says nothing about whose profile they may open, + /// so the subject is authorized with the same rule the page itself runs on. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Department_View)] + public async Task RevealUserProfile([FromForm] string userId) + { + if (string.IsNullOrWhiteSpace(userId)) + userId = UserId; + + if (!await _authorizationService.CanUserEditProfileAsync(UserId, DepartmentId, userId)) + return Unauthorized(); + + string grantToken = Request.Headers["X-Resgrid-Protected-Grant"]; + var fields = new Dictionary(); + + var sensitive = await _memberSensitiveDataService.GetByDepartmentAndUserAsync(DepartmentId, userId); + if (sensitive != null) + { + var resolvedSensitive = await _protectedReadService.ResolveMemberSensitiveDataForReadAsync( + DepartmentId, new[] { sensitive }, grantToken, UserId); + + if (resolvedSensitive.IsProtected && resolvedSensitive.ProtectedReason != null) + return Json(new { success = false, error = resolvedSensitive.ProtectedReason }); + + foreach (var accessor in Resgrid.Services.ProtectedReadService.MemberSensitiveDataAccessors) + fields[accessor.Key] = accessor.Value.Get(sensitive); + } + + var resolvedUdf = await ProtectedUdfRevealHelper.AddUdfValuesAsync(fields, _userDefinedFieldsService, + _protectedReadService, DepartmentId, UdfEntityType.Personnel, userId, grantToken, UserId); + + if (resolvedUdf != null && resolvedUdf.IsProtected && resolvedUdf.ProtectedReason != null) + return Json(new { success = false, error = resolvedUdf.ProtectedReason }); + + return Json(new { success = true, fields }); + } + #region Emergency contacts /// diff --git a/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs b/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs index 7f293533e..8bbb2d440 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/MessagesController.cs @@ -35,13 +35,15 @@ public class MessagesController : SecureBaseController private readonly IShiftsService _shiftsService; private readonly ICalendarService _calendarService; private readonly IModerationService _moderationService; + private readonly IProtectedReadService _protectedReadService; private readonly IStringLocalizer _moderationLocalizer; public MessagesController(IMessageService messageService, IDepartmentsService departmentsService, IUsersService usersService, ICommunicationService communicationService, Model.Services.IAuthorizationService authorizationService, IDepartmentGroupsService departmentGroupsService, IPersonnelRolesService personnelRolesService, IShiftsService shiftsService, ICalendarService calendarService, IModerationService moderationService, - IStringLocalizer moderationLocalizer) + IStringLocalizer moderationLocalizer, + IProtectedReadService protectedReadService) { _messageService = messageService; _departmentsService = departmentsService; @@ -54,6 +56,7 @@ public MessagesController(IMessageService messageService, IDepartmentsService de _calendarService = calendarService; _moderationService = moderationService; _moderationLocalizer = moderationLocalizer; + _protectedReadService = protectedReadService; } #endregion Private Members and Constructors @@ -65,6 +68,11 @@ public async Task Inbox() model.User = _usersService.GetUserById(UserId); model.Messages = await _messageService.GetInboxMessagesByUserIdAsync(UserId); + + // ADP (catalog v7): server-rendered pages always render protected values as REDACTED - a + // grant lives only in the browser, never in the server session. + await _protectedReadService.ResolveMessagesForReadAsync(DepartmentId, model.Messages, null, UserId); + model.UnreadMessages = await _messageService.GetUnreadMessagesCountByUserIdAsync(UserId); return View(model); @@ -80,6 +88,9 @@ public async Task Outbox() model.User = _usersService.GetUserById(UserId); model.Messages = await _messageService.GetSentMessagesByUserIdAsync(UserId); + // ADP: the sender sees placeholders too - the row is encrypted under the department key. + await _protectedReadService.ResolveMessagesForReadAsync(DepartmentId, model.Messages, null, UserId); + return View(model); } @@ -272,7 +283,7 @@ public async Task ViewMessage(int messageId, CancellationToken ca { var recipient = model.Message.MessageRecipients?.FirstOrDefault(x => x.UserId == UserId && !x.IsDeleted); if (recipient != null - && TextResponsePromptMetadata.TryGetCalendarItemId(recipient.Note, out var calendarItemId)) + && TextResponsePromptMetadata.TryGetCalendarItemId(recipient.PromptMetadata, out var calendarItemId)) { var calendarItem = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); if (calendarItem != null && calendarItem.DepartmentId == DepartmentId @@ -288,9 +299,44 @@ public async Task ViewMessage(int messageId, CancellationToken ca await _messageService.ReadMessageRecipientAsync(messageId, UserId, cancellationToken); + // ADP (catalog v7): REDACTED on the server, revealed client-side through RevealMessage. + var protectedRead = await _protectedReadService.ResolveMessagesForReadAsync(DepartmentId, + new List { model.Message }, null, UserId); + model.IsProtectedMessage = protectedRead.IsProtected; + return View(model); } + /// + /// ADP client-side reveal (plan 7.2). A grant proves the CALLER stepped up; it is never an + /// authorization decision about the target, so the message is authorized exactly as the page + /// hosting the reveal authorizes it. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Messages_View)] + public async Task RevealMessage([FromForm] int messageId) + { + if (!await _authorizationService.CanUserViewMessageAsync(UserId, messageId)) + return Unauthorized(); + + var message = await _messageService.GetMessageByIdAsync(messageId); + if (message == null) + return NotFound(); + + string grantToken = Request.Headers["X-Resgrid-Protected-Grant"]; + var resolved = await _protectedReadService.ResolveMessagesForReadAsync(DepartmentId, + new List { message }, grantToken, UserId); + + if (resolved.IsProtected && resolved.ProtectedReason != null) + return Json(new { success = false, error = resolved.ProtectedReason }); + + var fields = Resgrid.Services.ProtectedReadService.MessageFieldAccessors + .ToDictionary(a => a.Key, a => a.Value.Get(message)); + + return Json(new { success = true, fields }); + } + [HttpPost] [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Messages_View)] @@ -326,7 +372,7 @@ public async Task CalendarRsvp(int messageId, bool attending, Can var recipient = message.MessageRecipients?.FirstOrDefault(x => x.UserId == UserId && !x.IsDeleted); if (recipient == null - || !TextResponsePromptMetadata.TryGetCalendarItemId(recipient.Note, out var calendarItemId)) + || !TextResponsePromptMetadata.TryGetCalendarItemId(recipient.PromptMetadata, out var calendarItemId)) return Unauthorized(); var calendarItem = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index e74d727d6..8e6480797 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -62,6 +62,7 @@ public class PersonnelController : SecureBaseController private readonly IStringLocalizer _localizer; private readonly IPhoneNumberProcesserProvider _phoneNumberProcesser; private readonly IExternalIdentityLinkService _externalIdentityLinkService; + private readonly IProtectedReadService _protectedReadService; public PersonnelController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, IEmailService emailService, IUserProfileService userProfileService, IDeleteService deleteService, Model.Services.IAuthorizationService authorizationService, @@ -70,7 +71,7 @@ public PersonnelController(IDepartmentsService departmentsService, IUsersService IGeoService geoService, UserManager userManager, IDepartmentSettingsService departmentSettingsService, ICallsService callsService, IGeoLocationProvider geoLocationProvider, IMappingService mappingService, IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, IStringLocalizer localizer, IPhoneNumberProcesserProvider phoneNumberProcesser, - IExternalIdentityLinkService externalIdentityLinkService) + IExternalIdentityLinkService externalIdentityLinkService, IProtectedReadService protectedReadService) { _departmentsService = departmentsService; _usersService = usersService; @@ -98,6 +99,7 @@ public PersonnelController(IDepartmentsService departmentsService, IUsersService _localizer = localizer; _phoneNumberProcesser = phoneNumberProcesser; _externalIdentityLinkService = externalIdentityLinkService; + _protectedReadService = protectedReadService; } #endregion Private Members and Constructors @@ -460,12 +462,45 @@ public async Task ViewPerson(string userId) var udfValues = await _userDefinedFieldsService.GetFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Personnel, userId); var visibleFieldIds = udfFields.Select(f => f.UdfFieldId).ToHashSet(); var filteredValues = (udfValues ?? new List()).Where(v => visibleFieldIds.Contains(v.UdfFieldId)).ToList(); + + // ADP: the renderer turns an envelope into the REDACTED placeholder, so an enveloped + // value here is exactly "this page has something a grant could reveal". Asking the + // values costs nothing extra - they are already loaded. + model.IsProtectedRecord = filteredValues.Any(v => ProtectedDataEnvelope.HasEnvelopePrefix(v.Value)); + model.UdfReadOnlyHtml = _udfRenderingService.GenerateReadOnlyHtml(udfDefinition, udfFields, filteredValues); } return View(model); } + /// + /// ADP client-side reveal (plan 7.2). A grant proves the CALLER stepped up; it is never an + /// authorization decision about the target, so the subject is authorized exactly as the page + /// that hosts the reveal authorizes it. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Personnel_View)] + public async Task RevealPerson([FromForm] string userId) + { + if (String.IsNullOrWhiteSpace(userId)) + return BadRequest(); + + if (!await _authorizationService.CanUserViewUserAsync(UserId, userId)) + return Unauthorized(); + + var fields = new Dictionary(); + var resolved = await ProtectedUdfRevealHelper.AddUdfValuesAsync(fields, _userDefinedFieldsService, + _protectedReadService, DepartmentId, UdfEntityType.Personnel, userId, + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); + + if (resolved != null && resolved.IsProtected && resolved.ProtectedReason != null) + return Json(new { success = false, error = resolved.ProtectedReason }); + + return Json(new { success = true, fields }); + } + [HttpPost] [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Personnel_Create)] diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs index cf41185ab..5b2f63586 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs @@ -633,7 +633,9 @@ await _scheduledTasksService.GetUpcomingScheduledTasksByUserIdTaskTypeAsync(user else person.StaffingTimestamp = staffing.Timestamp.FormatForDepartment(model.Department); - person.StaffingNote = staffing.Note; + // ADP (catalog v9): every other staffing note in this file is masked; this one was + // missed and would have printed the envelope into the report and its PDF. + person.StaffingNote = ProtectedDataEnvelope.SafeDisplay(staffing.Note); GroupBreakdown groupBreakdown = model.GroupBreakdowns.FirstOrDefault(x => x.Name == person.Group); bool isNew = false; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs index 2cdccf426..98c48a16a 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/UnitsController.cs @@ -50,12 +50,13 @@ public class UnitsController : SecureBaseController private readonly IUdfRenderingService _udfRenderingService; private readonly IStringLocalizer _localizer; private readonly IPersonnelRolesService _personnelRolesService; + private readonly IProtectedReadService _protectedReadService; public UnitsController(IDepartmentsService departmentsService, IUsersService usersService, IUnitsService unitsService, Model.Services.IAuthorizationService authorizationService, ILimitsService limitsService, IDepartmentGroupsService departmentGroupsService, ICallsService callsService, IEventAggregator eventAggregator, ICustomStateService customStateService, IGeoService geoService, IDepartmentSettingsService departmentSettingsService, IGeoLocationProvider geoLocationProvider, INovuProvider novuProvider, IMappingService mappingService, IUserDefinedFieldsService userDefinedFieldsService, IUdfRenderingService udfRenderingService, IStringLocalizer localizer, - IPersonnelRolesService personnelRolesService) + IPersonnelRolesService personnelRolesService, IProtectedReadService protectedReadService) { _departmentsService = departmentsService; _usersService = usersService; @@ -75,6 +76,7 @@ public UnitsController(IDepartmentsService departmentsService, IUsersService use _udfRenderingService = udfRenderingService; _localizer = localizer; _personnelRolesService = personnelRolesService; + _protectedReadService = protectedReadService; } #endregion Private Members and Constructors @@ -517,12 +519,79 @@ public async Task EditUnit(int unitId) var udfValues = await _userDefinedFieldsService.GetFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Unit, unitId.ToString()); var visibleFieldIds = udfFields.Select(f => f.UdfFieldId).ToHashSet(); var filteredValues = (udfValues ?? new List()).Where(v => visibleFieldIds.Contains(v.UdfFieldId)).ToList(); + + // ADP: the renderer turns an envelope into the REDACTED placeholder, so an enveloped + // value here is exactly "this page has something a grant could reveal". The values + // are already loaded, so the check costs nothing. + model.IsProtectedRecord = filteredValues.Any(v => ProtectedDataEnvelope.HasEnvelopePrefix(v.Value)); + model.UdfFormHtml = _udfRenderingService.GenerateHtmlFormFields(udfDefinition, udfFields, filteredValues); } return View(model); } + /// + /// ADP client-side reveal (plan 7.2) for a unit's log narratives. Keyed per row + /// ("unitlogs.narrative:{id}") because the page shows a list, not one record - the same + /// shape the user-defined field reveal uses. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.UnitLog_View)] + public async Task RevealUnitLogs([FromForm] int unitId) + { + if (!await _authorizationService.CanUserViewUnitAsync(UserId, unitId)) + return Unauthorized(); + + var unit = await _unitsService.GetUnitByIdAsync(unitId); + if (unit == null || unit.DepartmentId != DepartmentId) + return NotFound(); + + var logs = await _unitsService.GetLogsForUnitAsync(unitId); + string grantToken = Request.Headers["X-Resgrid-Protected-Grant"]; + + var resolved = await _protectedReadService.ResolveUnitLogsForReadAsync(DepartmentId, logs, + grantToken, UserId); + + if (resolved.IsProtected && resolved.ProtectedReason != null) + return Json(new { success = false, error = resolved.ProtectedReason }); + + var fields = new Dictionary(); + foreach (var log in logs ?? new List()) + fields[$"unitlogs.narrative:{log.UnitLogId}"] = log.Narrative; + + return Json(new { success = true, fields }); + } + + /// + /// ADP client-side reveal (plan 7.2). A grant proves the CALLER stepped up; it is never an + /// authorization decision about the target, so the unit is authorized exactly as the edit + /// page that hosts the reveal authorizes it. + /// + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Unit_Update)] + public async Task RevealUnit([FromForm] int unitId) + { + if (!await _authorizationService.CanUserModifyUnitAsync(UserId, unitId)) + return Unauthorized(); + + var unit = await _unitsService.GetUnitByIdAsync(unitId); + if (unit == null || unit.DepartmentId != DepartmentId) + return NotFound(); + + var fields = new Dictionary(); + var resolved = await ProtectedUdfRevealHelper.AddUdfValuesAsync(fields, _userDefinedFieldsService, + _protectedReadService, DepartmentId, UdfEntityType.Unit, unitId.ToString(), + Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); + + if (resolved != null && resolved.IsProtected && resolved.ProtectedReason != null) + return Json(new { success = false, error = resolved.ProtectedReason }); + + return Json(new { success = true, fields }); + } + [HttpPost] [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Unit_Update)] @@ -908,6 +977,12 @@ public async Task ViewLogs(int unitId) model.Logs = await _unitsService.GetLogsForUnitAsync(model.Unit.UnitId); + // ADP (catalog v9): server-rendered pages render narratives as REDACTED; the reveal is + // client-side (step-up modal then RevealUnitLogs). + var protectedRead = await _protectedReadService.ResolveUnitLogsForReadAsync(DepartmentId, + model.Logs, null, UserId); + model.IsProtectedLogs = protectedRead.IsProtected; + return View(model); } diff --git a/Web/Resgrid.Web/Areas/User/Models/AdpRevealView.cs b/Web/Resgrid.Web/Areas/User/Models/AdpRevealView.cs new file mode 100644 index 000000000..0a3ae5cae --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/AdpRevealView.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; + +namespace Resgrid.Web.Areas.User.Models +{ + /// + /// Everything the shared ADP reveal partials need (plan 7.2). A server-rendered page never holds + /// a Protected Data Grant — the banner, the step-up modal and the reveal call all live in the + /// browser — so every host page repeats the same markup and the same nine localized error + /// strings. This model lets both partials be dropped into a view with two lines. + /// + public class AdpRevealView + { + /// + /// Localized subject line for the banner ("Protected call", "Protected unit record"). The + /// host resolves it, because only the host knows what record the page is showing. + /// + public string BannerTitle { get; set; } + + /// The action that returns decrypted values for a grant-holding caller. + public string RevealAction { get; set; } + + /// The controller hosting . + public string RevealController { get; set; } + + /// + /// Form values identifying the record being revealed (callId, contactId, userId, unitId). + /// Posted alongside the antiforgery token; the reveal action authorizes the SUBJECT named + /// here on top of validating the grant — a grant proves the caller stepped up, never that + /// they may read this particular record. + /// + public Dictionary RevealData { get; set; } = new Dictionary(); + } +} diff --git a/Web/Resgrid.Web/Areas/User/Models/Calendar/EditCalendarEntry.cs b/Web/Resgrid.Web/Areas/User/Models/Calendar/EditCalendarEntry.cs index 54bac0f1d..0c4fd71b9 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calendar/EditCalendarEntry.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calendar/EditCalendarEntry.cs @@ -8,6 +8,9 @@ public class EditCalendarEntry { public string Message { get; set; } public CalendarItem Item { get; set; } + + /// True when this page is showing ADP placeholders that a grant could reveal. + public bool IsProtectedItem { get; set; } public RecurrenceTypes RecurrenceTypes { get; set; } public List Types { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs b/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs index f858dc659..c5ac22cd8 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Calls/UpdateCallView.cs @@ -9,6 +9,10 @@ namespace Resgrid.Web.Areas.User.Models.Calls public class UpdateCallView: BaseUserModel { public string UdfFormHtml { get; set; } + + /// True when this page is showing ADP placeholders that a grant could reveal. + public bool IsProtectedCall { get; set; } + public Department Department { get; set; } public IdentityUser User { get; set; } public Call Call { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Contacts/EditContactView.cs b/Web/Resgrid.Web/Areas/User/Models/Contacts/EditContactView.cs index c9263536c..f8183fe23 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Contacts/EditContactView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Contacts/EditContactView.cs @@ -6,6 +6,9 @@ namespace Resgrid.Web.Areas.User.Models.Contacts; public class EditContactView { public string UdfFormHtml { get; set; } + + /// True when this page is showing ADP placeholders that a grant could reveal. + public bool IsProtectedContact { get; set; } public string Message { get; set; } public Department Department { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Documents/ViewDocumentView.cs b/Web/Resgrid.Web/Areas/User/Models/Documents/ViewDocumentView.cs index 3b3849c85..7accfc2f4 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Documents/ViewDocumentView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Documents/ViewDocumentView.cs @@ -5,6 +5,9 @@ namespace Resgrid.Web.Areas.User.Models.Documents public class ViewDocumentView { public Document Document { get; set; } + /// True when this page is showing ADP placeholders that a grant could reveal. + public bool IsProtectedDocument { get; set; } + public Department Department { get; set; } public string UploadedByName { get; set; } public string DescriptionHtml { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/EditProfileModel.cs b/Web/Resgrid.Web/Areas/User/Models/EditProfileModel.cs index adf7bb264..d693e4acd 100644 --- a/Web/Resgrid.Web/Areas/User/Models/EditProfileModel.cs +++ b/Web/Resgrid.Web/Areas/User/Models/EditProfileModel.cs @@ -11,6 +11,13 @@ public class EditProfileModel: BaseUserModel { public string ApiUrl { get; set; } public string UdfFormHtml { get; set; } + + /// + /// True when this department encrypts member data: the identification number, addresses, + /// emergency contacts and custom fields on this page render as placeholders until the + /// member steps up. Drives the reveal banner. + /// + public bool IsProtectedProfile { get; set; } public Department Department { get; set; } public IdentityUser User { get; set; } public bool Self { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Messages/ViewMessageView.cs b/Web/Resgrid.Web/Areas/User/Models/Messages/ViewMessageView.cs index 900af3a4c..1dd1aaea4 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Messages/ViewMessageView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Messages/ViewMessageView.cs @@ -10,6 +10,9 @@ public class ViewMessageView: BaseUserModel public IdentityUser User { get; set; } public Message Message { get; set; } public int UnreadMessages { get; set; } + + /// True when this page is showing ADP placeholders that a grant could reveal. + public bool IsProtectedMessage { get; set; } public List UserGroupsAndRoles { get; set; } public bool CanRespondToCalendarRsvp { get; set; } public int? CalendarRsvpAttendeeType { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonView.cs b/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonView.cs index 73f4c614c..d9bf990ce 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonView.cs @@ -6,6 +6,9 @@ namespace Resgrid.Web.Areas.User.Models.Personnel public class ViewPersonView { public string UdfReadOnlyHtml { get; set; } + + /// True when this page is showing ADP placeholders that a grant could reveal. + public bool IsProtectedRecord { get; set; } public IdentityUser User { get; set; } public UserProfile Profile { get; set; } public DepartmentGroup Group { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Units/NewUnitView.cs b/Web/Resgrid.Web/Areas/User/Models/Units/NewUnitView.cs index 9563b433c..6bb18ea42 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Units/NewUnitView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Units/NewUnitView.cs @@ -7,6 +7,9 @@ namespace Resgrid.Web.Areas.User.Models.Units public class NewUnitView : BaseUserModel { public string UdfFormHtml { get; set; } + + /// True when this page is showing ADP placeholders that a grant could reveal. + public bool IsProtectedRecord { get; set; } public Unit Unit { get; set; } public List Types { get; set; } public List Stations { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Units/ViewLogsView.cs b/Web/Resgrid.Web/Areas/User/Models/Units/ViewLogsView.cs index 529c239f2..214adcd63 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Units/ViewLogsView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Units/ViewLogsView.cs @@ -9,6 +9,9 @@ public class ViewLogsView : BaseUserModel public Unit Unit { get; set; } public List Logs { get; set; } + /// True when this page is showing ADP placeholders that a grant could reveal. + public bool IsProtectedLogs { get; set; } + public bool ConfirmClearAll { get; set; } public string Message { get; set; } public string OSMKey { get; set; } diff --git a/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml index 510b9028d..47d6eaeba 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Calendar/Edit.cshtml @@ -4,6 +4,27 @@ ViewBag.Title = "Resgrid | " + @localizer["EditEntryeHeader"]; } +@{ + // ADP reveal (plan 7.2): one descriptor shared by the banner in the body and the step-up + // wiring in the Scripts section. Declared unconditionally so the section closure can see it. + var adpReveal = new Resgrid.Web.Areas.User.Models.AdpRevealView + { + BannerTitle = commonLocalizer["AdpProtectedCalendarEntry"], + RevealController = "Calendar", + RevealAction = "RevealCalendarItem", + RevealData = new Dictionary { { "calendarItemId", Model.Item.CalendarItemId.ToString() } } + }; +} + +@if (Model.IsProtectedItem) +{ +
    +
    + +
    +
    +} + @section Styles { @@ -77,7 +98,7 @@ }
    -
    +
    @@ -301,7 +322,7 @@
    -
    +
    @*
    @@ -398,6 +419,10 @@ @section Scripts { + @if (Model.IsProtectedItem) + { + + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml index e6f586705..728f560c6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Contacts/View.cshtml @@ -7,14 +7,22 @@ }
    +@{ + // ADP reveal (plan 7.2): one descriptor shared by the banner in the body and the + // step-up wiring in the Scripts section. Declared unconditionally so the section + // closure can see it; both partials only render when the page is protected. + var adpReveal = new Resgrid.Web.Areas.User.Models.AdpRevealView + { + BannerTitle = commonLocalizer["AdpProtectedContact"], + RevealController = "Contacts", + RevealAction = "RevealContact", + RevealData = new Dictionary { { "contactId", Model.Contact.ContactId } } + }; +} + @if (Model.IsProtectedContact) { -
    - - @commonLocalizer["AdpProtectedContact"] — @commonLocalizer["AdpProtectedBannerDescription"] - - -
    + }
    @@ -608,60 +616,12 @@
    -@if (Model.IsProtectedContact) -{ - -} @section Scripts { @if (Model.IsProtectedContact) { -
    @Html.AntiForgeryToken()
    - - + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml index 69ae1de3b..a7a5704b3 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Dispatch/UpdateCall.cshtml @@ -5,6 +5,19 @@ Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; var mapConfig = SettingsHelper.GetDepartmentMapConfig(Resgrid.Config.InfoConfig.WebsiteKey); } +@{ + // ADP reveal (plan 7.2): one descriptor shared by the banner in the body and the + // step-up wiring in the Scripts section. Declared unconditionally so the section + // closure can see it; both partials only render when the page is protected. + var adpReveal = new Resgrid.Web.Areas.User.Models.AdpRevealView + { + BannerTitle = commonLocalizer["AdpProtectedCall"], + RevealController = "Dispatch", + RevealAction = "RevealCall", + RevealData = new Dictionary { { "callId", Model.Call.CallId.ToString() } } + }; +} + @section Styles {