From 4db02b4f4f1e4e98d51f5c6658a98f9845be4e22 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 2 Sep 2026 19:30:46 -0700 Subject: [PATCH 1/2] RG-T89 Disabed migration that was added to early to remove unused table data and column --- Core/Resgrid.Model/UserProfile.cs | 23 ++- .../M0141_ContractLegacyMemberProfileData.cs | 72 ++++---- ...M0141_ContractLegacyMemberProfileDataPg.cs | 80 ++++----- .../Services/MemberProfileRelocationTests.cs | 41 +++++ .../MemberProfileMigrationWritePathTests.cs | 119 +++++++++++++ .../Areas/User/Controllers/HomeController.cs | 156 ++++++++++++------ .../User/Controllers/PersonnelController.cs | 24 ++- 7 files changed, 381 insertions(+), 134 deletions(-) create mode 100644 Tests/Resgrid.Tests/Web/User/MemberProfileMigrationWritePathTests.cs diff --git a/Core/Resgrid.Model/UserProfile.cs b/Core/Resgrid.Model/UserProfile.cs index 6c4f1249..56662fc6 100644 --- a/Core/Resgrid.Model/UserProfile.cs +++ b/Core/Resgrid.Model/UserProfile.cs @@ -44,9 +44,17 @@ public class UserProfile: IEntity [ProtoMember(8)] public string HomeNumber { get; set; } + /// + /// Legacy migration source for the department-scoped home address. Current application + /// writes exclude this property through IgnoredProperties while Dapper reads still hydrate it. + /// [ProtoMember(9)] public int? HomeAddressId { get; set; } + /// + /// Legacy migration source for the department-scoped mailing address. Current application + /// writes exclude this property through IgnoredProperties while Dapper reads still hydrate it. + /// [ProtoMember(10)] public int? MailingAddressId { get; set; } @@ -81,13 +89,16 @@ public class UserProfile: IEntity 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. + /// LEGACY MIGRATION SOURCE. A profile is global to a person across every department they + /// belong to, so new values live on DepartmentMemberSensitiveData per department and are + /// cataloged there. Until the later contract migration removes the column, Dapper SELECT * + /// queries deliberately still hydrate this property for MemberProfileRelocationService. + /// Generic inserts/updates ignore it, so current application paths cannot add or change + /// legacy values while the relocation is active. JsonIgnore also keeps the temporary + /// plaintext source out of profile JSON and audit payloads. /// [NotMapped] + [JsonIgnore] [ProtoMember(18)] public string IdentificationNumber { get; set; } @@ -251,7 +262,7 @@ public object IdValue public int IdType => 0; [NotMapped] - public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "User", "MembershipEmail", "IdentificationNumber" }; + public IEnumerable IgnoredProperties => new string[] { "IdValue", "IdType", "TableName", "IdName", "User", "MembershipEmail", "IdentificationNumber", "HomeAddressId", "MailingAddressId" }; [NotMapped] public FullNameFormat FullName diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0141_ContractLegacyMemberProfileData.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0141_ContractLegacyMemberProfileData.cs index 0ab9b145..8ea6bec2 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0141_ContractLegacyMemberProfileData.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0141_ContractLegacyMemberProfileData.cs @@ -38,41 +38,45 @@ 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;"); + // Note: This migration is in order by the migration code has not run against production, + // so this migration would have been applied and destryed the data. What this migration + // does will need to be recreated here in a little bit. - // 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"); +// 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() diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0141_ContractLegacyMemberProfileDataPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0141_ContractLegacyMemberProfileDataPg.cs index 7883233f..1c6c550e 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0141_ContractLegacyMemberProfileDataPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0141_ContractLegacyMemberProfileDataPg.cs @@ -38,44 +38,48 @@ 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"); + // Note: This migration is in order by the migration code has not run against production, + // so this migration would have been applied and destryed the data. What this migration + // does will need to be recreated here in a little bit. + +// 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() diff --git a/Tests/Resgrid.Tests/Services/MemberProfileRelocationTests.cs b/Tests/Resgrid.Tests/Services/MemberProfileRelocationTests.cs index e9ded5e3..60dde361 100644 --- a/Tests/Resgrid.Tests/Services/MemberProfileRelocationTests.cs +++ b/Tests/Resgrid.Tests/Services/MemberProfileRelocationTests.cs @@ -1,14 +1,19 @@ using System; using System.Collections.Generic; +using System.Data; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Dapper; using FluentAssertions; using Moq; +using Newtonsoft.Json; using NUnit.Framework; using Resgrid.Model; using Resgrid.Model.Repositories; using Resgrid.Model.Services; +using Resgrid.Repositories.DataRepository.Extensions; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; using Resgrid.Services; namespace Resgrid.Tests.Services @@ -108,6 +113,42 @@ public async Task Legacy_identification_number_and_addresses_move_onto_the_depar row.LegacyProfileRelocatedOn.Should().NotBeNull(); } + [Test] + public void Legacy_profile_fields_are_readable_for_relocation_but_not_generically_written() + { + // Dapper maps SELECT * independently of EF's NotMapped attribute and the custom write + // exclusions, so it still hydrates the legacy sources while the generic Resgrid + // insert/update builder keeps current application writes away from the old columns. + var table = new DataTable(); + table.Columns.Add(nameof(UserProfile.UserId), typeof(string)); + table.Columns.Add(nameof(UserProfile.IdentificationNumber), typeof(string)); + table.Columns.Add(nameof(UserProfile.HomeAddressId), typeof(int)); + table.Columns.Add(nameof(UserProfile.MailingAddressId), typeof(int)); + table.Rows.Add("user-1", "BADGE-7", 101, 202); + + using var reader = table.CreateDataReader(); + reader.Read().Should().BeTrue(); + var profile = reader.GetRowParser()(reader); + + profile.IdentificationNumber.Should().Be("BADGE-7", + "MemberProfileRelocationService must still be able to read the retained source column"); + profile.HomeAddressId.Should().Be(101); + profile.MailingAddressId.Should().Be(202); + profile.IgnoredProperties.Should().Contain(new[] + { + nameof(UserProfile.IdentificationNumber), nameof(UserProfile.HomeAddressId), + nameof(UserProfile.MailingAddressId) + }, "new profile inserts and edits must never write the legacy global values"); + var writeColumns = profile.GetColumns(new SqlServerConfiguration(), + ignoreProperties: profile.IgnoredProperties).ToList(); + writeColumns.Should().NotContain(column => + column.Contains(nameof(UserProfile.IdentificationNumber), StringComparison.OrdinalIgnoreCase) || + column.Contains(nameof(UserProfile.HomeAddressId), StringComparison.OrdinalIgnoreCase) || + column.Contains(nameof(UserProfile.MailingAddressId), StringComparison.OrdinalIgnoreCase)); + JsonConvert.SerializeObject(profile).Should().NotContain($"\"{nameof(UserProfile.IdentificationNumber)}\":", + "the temporary plaintext source must not bypass the department-scoped read path"); + } + [Test] public async Task A_department_specific_value_is_never_overwritten() { diff --git a/Tests/Resgrid.Tests/Web/User/MemberProfileMigrationWritePathTests.cs b/Tests/Resgrid.Tests/Web/User/MemberProfileMigrationWritePathTests.cs new file mode 100644 index 00000000..1111f3dc --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/MemberProfileMigrationWritePathTests.cs @@ -0,0 +1,119 @@ +using System; +using System.IO; +using System.Text.RegularExpressions; +using FluentAssertions; +using NUnit.Framework; + +namespace Resgrid.Tests.Web.User +{ + /// + /// Migration 141's contract work is intentionally deferred. During the expand/relocate window, + /// every application write for the fields it will eventually remove must land on + /// DepartmentMemberSensitiveData, while the UserProfiles values remain read-only migration + /// sources. These structural checks cover both forms that accept an identification number and + /// the only form that accepts the moved addresses. + /// + [TestFixture] + public class MemberProfileMigrationWritePathTests + { + 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!; + } + + private static string ControllerSource(string controller) + { + var path = Path.Combine(RepositoryRoot().FullName, "Web", "Resgrid.Web", "Areas", "User", + "Controllers", controller); + File.Exists(path).Should().BeTrue($"expected the controller at {path}"); + return File.ReadAllText(path); + } + + private static string MethodBody(string source, string signature) + { + var start = source.IndexOf(signature, StringComparison.Ordinal); + start.Should().BeGreaterThan(0, $"expected method signature {signature}"); + + 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 Add_person_writes_moved_fields_to_the_department_row_only() + { + var body = MethodBody(ControllerSource("PersonnelController.cs"), + "public async Task AddPerson(AddPersonModel model"); + + var clearLegacy = body.IndexOf("model.Profile.IdentificationNumber = null;", StringComparison.Ordinal); + var saveProfile = body.IndexOf("_userProfileService.SaveProfileAsync", StringComparison.Ordinal); + var addMembership = body.IndexOf("_departmentsService.AddUserToDepartmentAsync", StringComparison.Ordinal); + var saveDepartmentValue = body.IndexOf("_memberSensitiveDataService.SaveAsync", StringComparison.Ordinal); + + clearLegacy.Should().BeGreaterThan(0, "the posted value must not reach the global profile writer"); + clearLegacy.Should().BeLessThan(saveProfile); + body.Should().Contain("model.Profile.HomeAddressId = null;", + "an overposted legacy home-address link must not reach a new global profile row"); + body.Should().Contain("model.Profile.MailingAddressId = null;", + "an overposted legacy mailing-address link must not reach a new global profile row"); + saveDepartmentValue.Should().BeGreaterThan(addMembership, + "the department-scoped row should be created only after its membership exists"); + body.Should().Contain("IdentificationNumber = identificationNumber"); + body.Should().Contain("LegacyProfileRelocatedOn = DateTime.UtcNow", + "a brand-new profile has no legacy source for the relocation worker to revisit"); + } + + [Test] + public void Edit_profile_writes_all_moved_fields_to_one_department_row() + { + var source = ControllerSource("HomeController.cs"); + var post = MethodBody(source, + "public async Task EditUserProfile(EditProfileModel model"); + var writer = MethodBody(source, + "private async Task SaveMemberSensitiveProfileAsync(EditProfileModel model"); + + post.Should().Contain("SaveMemberSensitiveProfileAsync(model, savedProfile, cancellationToken)"); + post.Should().NotContain("savedProfile.IdentificationNumber = model.Profile.IdentificationNumber", + "the global profile column is a read-only migration source"); + + foreach (var assignment in new[] + { + "sensitive.IdentificationNumber = v", + "sensitive.HomeAddress1 = v", "sensitive.HomeCity = v", "sensitive.HomeState = v", + "sensitive.HomePostalCode = v", "sensitive.HomeCountry = v", + "sensitive.MailingAddress1 = v", "sensitive.MailingCity = v", "sensitive.MailingState = v", + "sensitive.MailingPostalCode = v", "sensitive.MailingCountry = v" + }) + { + writer.Should().Contain(assignment); + } + + writer.Should().Contain("value == ProtectedDataEnvelope.RedactionValue", + "an unrevealed protected value must never be overwritten by its UI sentinel"); + writer.Should().Contain("sensitive.LegacyProfileRelocatedOn = DateTime.UtcNow", + "an unprotected edit of the complete form must win over stale legacy values"); + } + + [Test] + public void Pre_contract_fallback_is_read_only_guarded_and_stops_after_relocation() + { + var source = ControllerSource("HomeController.cs"); + var get = MethodBody(source, + "public async Task EditUserProfile(string userId)"); + + get.Should().Contain("!protectionEnforced"); + get.Should().Contain("!memberAddresses.LegacyProfileRelocatedOn.HasValue", + "a blank target after the marker can be an intentional clear and must not fall back"); + get.Should().Contain("model.Profile.HomeAddressId.Value"); + get.Should().Contain("model.Profile.MailingAddressId.Value"); + get.Should().NotContain("savedProfile.HomeAddressId =", + "legacy addresses are displayed only to bridge the relocation window, never rewritten"); + } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index 24e98d56..70789df6 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -437,7 +437,8 @@ public async Task EditUserProfile(string userId) model.Email = model.User.Email; model.Profile = await _userProfileService.GetProfileByUserIdAsync(userId, true); - await HydrateMemberIdentificationNumberAsync(model, userId); + var protectionEnforced = await _dataProtectionService.IsProtectionEnforcedAsync(DepartmentId); + await HydrateMemberIdentificationNumberAsync(model, userId, protectionEnforced); if (model.Profile == null) model.Profile = new UserProfile(); @@ -463,10 +464,12 @@ public async Task EditUserProfile(string userId) await _protectedReadService.ResolveMemberSensitiveDataForReadAsync(DepartmentId, new[] { memberAddresses }, Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); - // 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); + // During the expand/relocate window, an unprotected member whose target row has not been + // stamped may still need the legacy address as a read-only fallback. Never fall back after + // relocation (a blank target can be an intentional clear), and never bypass ADP for a + // protected department by rendering the plaintext legacy copy. + var legacyAddressFallbackAllowed = !protectionEnforced && + (memberAddresses == null || !memberAddresses.LegacyProfileRelocatedOn.HasValue); // 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 @@ -481,6 +484,18 @@ 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); + if (homeAddress != null) + { + 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)) { @@ -513,6 +528,26 @@ 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); + if (mailingAddress != null) + { + 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; @@ -773,7 +808,7 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo auditEvent.ServerName = Environment.MachineName; auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; - var savedProfile = await _userProfileService.GetProfileByUserIdAsync(model.UserId); + var savedProfile = await _userProfileService.GetProfileByUserIdAsync(model.UserId, true); if (savedProfile == null) savedProfile = new UserProfile(); @@ -801,13 +836,9 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo savedProfile.HomeNumber = (homeResult != null && homeResult.IsValid && !string.IsNullOrWhiteSpace(homeResult.InternationalNumber)) ? homeResult.InternationalNumber : model.Profile.HomeNumber; - // 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); + // Identification number and addresses are department-scoped. The legacy profile + // fields remain read-only migration sources throughout the expand/relocate window. + await SaveMemberSensitiveProfileAsync(model, savedProfile, cancellationToken); savedProfile.TimeZone = model.Profile.TimeZone; savedProfile.Language = model.Profile.Language; @@ -873,7 +904,7 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo } // 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 + // profile. SaveMemberSensitiveProfileAsync 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; @@ -1546,7 +1577,8 @@ public class EmergencyContactInput /// 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) + private async Task HydrateMemberIdentificationNumberAsync(EditProfileModel model, string userId, + bool protectionEnforced) { if (model?.Profile == null) return; @@ -1554,24 +1586,42 @@ private async Task HydrateMemberIdentificationNumberAsync(EditProfileModel model var sensitive = await _memberSensitiveDataService.GetByDepartmentAndUserAsync(DepartmentId, userId); if (sensitive == null) { - model.Profile.IdentificationNumber = null; + // Dapper still hydrates the ignored legacy column during the relocation window. It is + // safe as a read-only fallback only for an unprotected department; a protected + // department must wait for the worker to move it through the encrypted write path. + if (protectionEnforced) + model.Profile.IdentificationNumber = null; return; } await _protectedReadService.ResolveMemberSensitiveDataForReadAsync(DepartmentId, new[] { sensitive }, Request.Headers["X-Resgrid-Protected-Grant"].ToString(), UserId); - model.Profile.IdentificationNumber = sensitive.IdentificationNumber; + // Once stamped, an empty target is authoritative (the member may have cleared it). Before + // the stamp, an unprotected row with an empty target can still read the legacy source so + // this edit itself completes the move instead of presenting a surprising blank. + if (protectionEnforced || sensitive.LegacyProfileRelocatedOn.HasValue || + !string.IsNullOrWhiteSpace(sensitive.IdentificationNumber)) + { + 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. + /// Persists all fields moved by the member-profile relocation as one department-scoped row. + /// Values still showing the REDACTED placeholder were never revealed to this user and are + /// skipped rather than written back over the stored value. + /// + /// An unprotected edit also completes the relocation marker. The GET action either loaded the + /// department value or supplied the guarded legacy fallback for every field, so the submitted + /// row is authoritative even when the user deliberately cleared a value. Protected departments + /// are left unstamped for MemberProfileRelocationService, which is the only path allowed to move + /// a plaintext legacy value into an enrolled row. /// - private async Task SaveMemberAddressesAsync(EditProfileModel model, CancellationToken cancellationToken) + private async Task SaveMemberSensitiveProfileAsync(EditProfileModel model, UserProfile legacyProfile, + CancellationToken cancellationToken) { - if (model == null) + if (model?.Profile == null) return; var sensitive = await _memberSensitiveDataService.GetByDepartmentAndUserAsync(DepartmentId, model.UserId); @@ -1591,18 +1641,37 @@ void Apply(string submitted, Action set) var home1 = model.PhysicalAddress1; var mailing1 = model.MailingAddressSameAsPhysical ? model.PhysicalAddress1 : model.MailingAddress1; + var mailingCity = model.MailingAddressSameAsPhysical ? model.PhysicalCity : model.MailingCity; + var mailingState = model.MailingAddressSameAsPhysical ? model.PhysicalState : model.MailingState; + var mailingPostalCode = model.MailingAddressSameAsPhysical ? model.PhysicalPostalCode : model.MailingPostalCode; + var mailingCountry = model.MailingAddressSameAsPhysical ? model.PhysicalCountry : model.MailingCountry; + + var submittedValues = new[] + { + model.Profile.IdentificationNumber, + home1, model.PhysicalCity, model.PhysicalState, model.PhysicalPostalCode, model.PhysicalCountry, + mailing1, mailingCity, mailingState, mailingPostalCode, mailingCountry + }; + + var protectionEnforced = await _dataProtectionService.IsProtectionEnforcedAsync(DepartmentId); + var canCompleteRelocation = !protectionEnforced && submittedValues.All(value => !Unchanged(value)); + var hasSubmittedData = submittedValues.Any(value => !Unchanged(value) && !string.IsNullOrWhiteSpace(value)); + var hasLegacyData = legacyProfile != null && + (!string.IsNullOrWhiteSpace(legacyProfile.IdentificationNumber) || legacyProfile.HomeAddressId.HasValue || + legacyProfile.MailingAddressId.HasValue); 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))) + // Usually an all-empty form needs no row. The exception is an unprotected profile + // that still has legacy data: an all-empty submission there is an intentional clear, + // so persist an empty, stamped target rather than letting the worker resurrect it. + if (!hasSubmittedData && !(canCompleteRelocation && hasLegacyData)) return; sensitive = new DepartmentMemberSensitiveData { DepartmentId = DepartmentId, UserId = model.UserId }; } + Apply(model.Profile.IdentificationNumber, v => sensitive.IdentificationNumber = v); Apply(home1, v => sensitive.HomeAddress1 = v); Apply(model.PhysicalCity, v => sensitive.HomeCity = v); Apply(model.PhysicalState, v => sensitive.HomeState = v); @@ -1613,36 +1682,13 @@ void Apply(string submitted, Action set) // 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 }; - } + Apply(mailingCity, v => sensitive.MailingCity = v); + Apply(mailingState, v => sensitive.MailingState = v); + Apply(mailingPostalCode, v => sensitive.MailingPostalCode = v); + Apply(mailingCountry, v => sensitive.MailingCountry = v); - sensitive.IdentificationNumber = identificationNumber; + if (canCompleteRelocation && !sensitive.LegacyProfileRelocatedOn.HasValue) + sensitive.LegacyProfileRelocatedOn = DateTime.UtcNow; await _memberSensitiveDataService.SaveAsync(sensitive, cancellationToken); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index d7e0c043..e241e603 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -63,6 +63,7 @@ public class PersonnelController : SecureBaseController private readonly IPhoneNumberProcesserProvider _phoneNumberProcesser; private readonly IExternalIdentityLinkService _externalIdentityLinkService; private readonly IProtectedReadService _protectedReadService; + private readonly IDepartmentMemberSensitiveDataService _memberSensitiveDataService; public PersonnelController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, IEmailService emailService, IUserProfileService userProfileService, IDeleteService deleteService, Model.Services.IAuthorizationService authorizationService, @@ -71,7 +72,8 @@ 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, IProtectedReadService protectedReadService) + IExternalIdentityLinkService externalIdentityLinkService, IProtectedReadService protectedReadService, + IDepartmentMemberSensitiveDataService memberSensitiveDataService) { _departmentsService = departmentsService; _usersService = usersService; @@ -100,6 +102,7 @@ public PersonnelController(IDepartmentsService departmentsService, IUsersService _phoneNumberProcesser = phoneNumberProcesser; _externalIdentityLinkService = externalIdentityLinkService; _protectedReadService = protectedReadService; + _memberSensitiveDataService = memberSensitiveDataService; } #endregion Private Members and Constructors @@ -607,6 +610,12 @@ public async Task AddPerson(AddPersonModel model, IFormCollection var result = await _userManager.CreateAsync(user, model.NewPassword); if (result.Succeeded) { + // IdentificationNumber is department-issued. Keep it off the new global profile + // row even while the legacy columns remain available as relocation sources. + var identificationNumber = model.Profile.IdentificationNumber; + model.Profile.IdentificationNumber = null; + model.Profile.HomeAddressId = null; + model.Profile.MailingAddressId = null; model.Profile.UserId = user.UserId; model.Profile.MobileCarrier = (int)model.Carrier; model.Profile.FirstName = model.FirstName; @@ -624,6 +633,19 @@ public async Task AddPerson(AddPersonModel model, IFormCollection await _departmentsService.AddUserToDepartmentAsync(DepartmentId, user.UserId, false, cancellationToken); + if (!string.IsNullOrWhiteSpace(identificationNumber)) + { + await _memberSensitiveDataService.SaveAsync(new DepartmentMemberSensitiveData + { + DepartmentId = DepartmentId, + UserId = user.UserId, + IdentificationNumber = identificationNumber, + // This is a brand-new profile with no legacy data to relocate. Stamping it + // prevents a later sweep from treating the row as an incomplete move. + LegacyProfileRelocatedOn = DateTime.UtcNow + }, cancellationToken); + } + if (model.MustChangePasswordOnLogin) { var member = await _departmentsService.GetDepartmentMemberAsync(user.UserId, DepartmentId); From ac99bd7c23cbf38ced04137f04711da3032a9c86 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 2 Sep 2026 20:46:34 -0700 Subject: [PATCH 2/2] RG-T89 PR #492 fixes --- .../MemberProfileMigrationWritePathTests.cs | 8 +++++++ .../Areas/User/Controllers/HomeController.cs | 3 ++- .../User/Controllers/PersonnelController.cs | 23 ++++++++++++------- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/Tests/Resgrid.Tests/Web/User/MemberProfileMigrationWritePathTests.cs b/Tests/Resgrid.Tests/Web/User/MemberProfileMigrationWritePathTests.cs index 1111f3dc..66b3b479 100644 --- a/Tests/Resgrid.Tests/Web/User/MemberProfileMigrationWritePathTests.cs +++ b/Tests/Resgrid.Tests/Web/User/MemberProfileMigrationWritePathTests.cs @@ -67,6 +67,11 @@ public void Add_person_writes_moved_fields_to_the_department_row_only() body.Should().Contain("IdentificationNumber = identificationNumber"); body.Should().Contain("LegacyProfileRelocatedOn = DateTime.UtcNow", "a brand-new profile has no legacy source for the relocation worker to revisit"); + body.Should().Contain("catch (InvalidOperationException ex)", + "a protected-write failure must not abandon the remaining user-creation steps"); + body.Should().Contain("Logging.LogException(ex);"); + body.Should().Contain("}, cancellationToken);", + "cancellation must still be passed to the department-scoped save"); } [Test] @@ -112,6 +117,9 @@ public void Pre_contract_fallback_is_read_only_guarded_and_stops_after_relocatio "a blank target after the marker can be an intentional clear and must not fall back"); get.Should().Contain("model.Profile.HomeAddressId.Value"); get.Should().Contain("model.Profile.MailingAddressId.Value"); + get.IndexOf("await HydrateMemberIdentificationNumberAsync", StringComparison.Ordinal).Should().BeGreaterThan( + get.IndexOf("model.Profile = new UserProfile();", StringComparison.Ordinal), + "the fallback profile must exist before department-scoped identification data is hydrated"); get.Should().NotContain("savedProfile.HomeAddressId =", "legacy addresses are displayed only to bridge the relocation window, never rewritten"); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index 70789df6..097227a3 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -438,11 +438,12 @@ public async Task EditUserProfile(string userId) model.Profile = await _userProfileService.GetProfileByUserIdAsync(userId, true); var protectionEnforced = await _dataProtectionService.IsProtectionEnforcedAsync(DepartmentId); - await HydrateMemberIdentificationNumberAsync(model, userId, protectionEnforced); if (model.Profile == null) model.Profile = new UserProfile(); + await HydrateMemberIdentificationNumberAsync(model, userId, protectionEnforced); + // Security PIN is only shown to the profile's owner (never to admins editing another user). model.DepartmentForcesSecurityPin = await _departmentSettingsService.GetForceChatbotSecurityPinAsync(DepartmentId); if (model.IsOwnProfile) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index e241e603..3cfa24ba 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -635,15 +635,22 @@ public async Task AddPerson(AddPersonModel model, IFormCollection if (!string.IsNullOrWhiteSpace(identificationNumber)) { - await _memberSensitiveDataService.SaveAsync(new DepartmentMemberSensitiveData + try { - DepartmentId = DepartmentId, - UserId = user.UserId, - IdentificationNumber = identificationNumber, - // This is a brand-new profile with no legacy data to relocate. Stamping it - // prevents a later sweep from treating the row as an incomplete move. - LegacyProfileRelocatedOn = DateTime.UtcNow - }, cancellationToken); + await _memberSensitiveDataService.SaveAsync(new DepartmentMemberSensitiveData + { + DepartmentId = DepartmentId, + UserId = user.UserId, + IdentificationNumber = identificationNumber, + // This is a brand-new profile with no legacy data to relocate. Stamping it + // prevents a later sweep from treating the row as an incomplete move. + LegacyProfileRelocatedOn = DateTime.UtcNow + }, cancellationToken); + } + catch (InvalidOperationException ex) + { + Logging.LogException(ex); + } } if (model.MustChangePasswordOnLogin)