From a9185a49eea808ba34db73ec4798b8362d3d0100 Mon Sep 17 00:00:00 2001 From: ShaynaCummings Date: Mon, 11 May 2026 18:14:46 -0400 Subject: [PATCH] DC-394: honor per-case-type address+view requirements when masking household data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PII visibility for address/email/phone was resolved against an empty case list every time, which is correct only for uniform requirements. For per-case-type IdProofingRequirements (e.g., the granular `address+view` form CO plans to adopt when co-loading begins in 2027) it silently degraded to IAL1 for every request — `IalRequirement.Resolve([])` short-circuits to IAL1 ("no cases = no case-derived reason to require elevated IAL"), so the per-case-type "highest wins" rule was never applied. `household+view` didn't have this gap because its evaluator already received the real cases; this change brings PII visibility into the same shape. Changes: - Add `IPiiVisibilityService.GetVisibility(UserIalLevel, IReadOnlyList)` overload; existing case-less overload delegates by passing `[]` so callers without case context keep their prior behavior. - `IdProofingService.EvaluateView` now takes cases and passes them to `IalRequirement.Resolve`, so per-case-type view requirements actually resolve against the user's cases. - Extract masking from `HouseholdRepository.ApplyPiiVisibility` into a shared `HouseholdPiiFilter.Apply` utility (Core.Utilities) so the use-case layer can re-apply visibility after fetching. - `GetHouseholdDataQueryHandler` now fetches with full PII, runs the existing `household+view` check against real cases, then resolves case-aware PII visibility and applies masking before returning. The visibility decision now happens once, against the data we're actually about to return. Out of scope (worth a follow-up if we want full symmetry): - `UpdateAddressCommandHandler` still uses the case-less overload. It doesn't expose household PII back to the client, so its visibility decision is functionally inert. - `MockHouseholdRepository.CreateCopy` duplicates the masking logic alongside its defensive deep copy; left untouched here to keep the diff scoped to the bug. Tests: - New `GetVisibility_PerCaseTypeAddressView_ResolvesAgainstActualCases` documents the gap (was a compile-error red before the overload was added; green now). - `Handle_WhenIdentifierResolvedAndHouseholdExistsButNotIdVerified_ReturnsSuccessWithoutAddress` reworked to provide a real address and assert it gets masked to "****" through the new code path, instead of relying on the test's pre-null AddressOnFile to make masking a no-op. - Default mocks added in `GetHouseholdDataQueryHandlerTests` and `HouseholdControllerTests` so existing tests pick up full PII from the new overload without per-test wiring. - A few `Assert.Same` checks against the repository's returned instance replaced with behavior-focused assertions, since the handler now produces a new record instance via `with`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../appsettings.co.example.json | 2 +- .../Services/IPiiVisibilityService.cs | 15 +++++++ .../Utilities/HouseholdPiiFilter.cs | 42 +++++++++++++++++++ .../Repositories/HouseholdRepository.cs | 31 +------------- .../Services/IdProofingService.cs | 15 ++++--- .../GetHouseholdDataQueryHandler.cs | 34 ++++++++++----- .../Controllers/HouseholdControllerTests.cs | 4 ++ .../Unit/Services/IdProofingServiceTests.cs | 35 ++++++++++++++++ .../GetHouseholdDataQueryHandlerTests.cs | 28 +++++++------ 9 files changed, 149 insertions(+), 57 deletions(-) create mode 100644 src/SEBT.Portal.Core/Utilities/HouseholdPiiFilter.cs diff --git a/src/SEBT.Portal.Api/appsettings.co.example.json b/src/SEBT.Portal.Api/appsettings.co.example.json index 33ea6b31b..730ff13a0 100644 --- a/src/SEBT.Portal.Api/appsettings.co.example.json +++ b/src/SEBT.Portal.Api/appsettings.co.example.json @@ -68,7 +68,7 @@ "Enabled": false }, "IdProofingRequirements": { - "address+view": "IAL1", + "address+view": "IAL1plus", "address+write": "IAL1plus", "email+view": "IAL1", "phone+view": "IAL1", diff --git a/src/SEBT.Portal.Core/Services/IPiiVisibilityService.cs b/src/SEBT.Portal.Core/Services/IPiiVisibilityService.cs index 97c1ef70b..a783e0f4f 100644 --- a/src/SEBT.Portal.Core/Services/IPiiVisibilityService.cs +++ b/src/SEBT.Portal.Core/Services/IPiiVisibilityService.cs @@ -1,5 +1,6 @@ using SEBT.Portal.Core.Models; using SEBT.Portal.Core.Models.Auth; +using SEBT.Portal.Core.Models.Household; namespace SEBT.Portal.Core.Services; @@ -10,5 +11,19 @@ namespace SEBT.Portal.Core.Services; /// public interface IPiiVisibilityService { + /// + /// Resolves visibility for a user without case context. Per-case-type + /// view requirements degrade to IAL1 here (no cases = no case-derived + /// reason to require elevated IAL), so callers that have access to + /// the user's cases should prefer the overload that accepts them. + /// PiiVisibility GetVisibility(UserIalLevel userIalLevel); + + /// + /// Resolves visibility against the user's actual cases. Required for + /// per-case-type view requirements (e.g. address+view) to apply + /// correctly — without the cases, the "highest wins" rule has nothing + /// to resolve against. + /// + PiiVisibility GetVisibility(UserIalLevel userIalLevel, IReadOnlyList cases); } diff --git a/src/SEBT.Portal.Core/Utilities/HouseholdPiiFilter.cs b/src/SEBT.Portal.Core/Utilities/HouseholdPiiFilter.cs new file mode 100644 index 000000000..3bd909b30 --- /dev/null +++ b/src/SEBT.Portal.Core/Utilities/HouseholdPiiFilter.cs @@ -0,0 +1,42 @@ +using SEBT.Portal.Core.Models; +using SEBT.Portal.Core.Models.Household; + +namespace SEBT.Portal.Core.Utilities; + +/// +/// Applies a to a , +/// masking fields the user is not authorized to see. Exposed as a shared +/// utility so the use-case layer can re-apply visibility once it has loaded +/// the household and can resolve per-case-type requirements against real cases. +/// +public static class HouseholdPiiFilter +{ + public static HouseholdData Apply(HouseholdData source, PiiVisibility piiVisibility) + { + return source with + { + Email = piiVisibility.IncludeEmail ? source.Email : PiiMasker.MaskEmail(source.Email), + Phone = piiVisibility.IncludePhone ? source.Phone : PiiMasker.MaskPhone(source.Phone), + AddressOnFile = piiVisibility.IncludeAddress && source.AddressOnFile != null + ? new Address + { + StreetAddress1 = source.AddressOnFile.StreetAddress1, + StreetAddress2 = source.AddressOnFile.StreetAddress2, + City = source.AddressOnFile.City, + State = source.AddressOnFile.State, + PostalCode = source.AddressOnFile.PostalCode + } + : source.AddressOnFile != null + ? new Address + { + StreetAddress1 = PiiMasker.MaskStreetAddress( + source.AddressOnFile.StreetAddress1, + source.AddressOnFile.StreetAddress2), + City = source.AddressOnFile.City, + State = source.AddressOnFile.State, + PostalCode = source.AddressOnFile.PostalCode + } + : null + }; + } +} diff --git a/src/SEBT.Portal.Infrastructure/Repositories/HouseholdRepository.cs b/src/SEBT.Portal.Infrastructure/Repositories/HouseholdRepository.cs index 16ce12ed3..4ea5efa8a 100644 --- a/src/SEBT.Portal.Infrastructure/Repositories/HouseholdRepository.cs +++ b/src/SEBT.Portal.Infrastructure/Repositories/HouseholdRepository.cs @@ -103,7 +103,7 @@ public HouseholdRepository( { return null; } - return ApplyPiiVisibility(core, piiVisibility); + return HouseholdPiiFilter.Apply(core, piiVisibility); } private static PluginHouseholdIdentifierType? MapToPluginIdentifierType(PreferredHouseholdIdType type) @@ -192,34 +192,7 @@ public Task TryMatchCoLoadedGuardianByBenefitIdAndDobAsync( return null; } - return ApplyPiiVisibility(core, piiVisibility); - } - - private static HouseholdData ApplyPiiVisibility(HouseholdData source, PiiVisibility piiVisibility) - { - return source with - { - Email = piiVisibility.IncludeEmail ? source.Email : PiiMasker.MaskEmail(source.Email), - Phone = piiVisibility.IncludePhone ? source.Phone : PiiMasker.MaskPhone(source.Phone), - AddressOnFile = piiVisibility.IncludeAddress && source.AddressOnFile != null - ? new Address - { - StreetAddress1 = source.AddressOnFile.StreetAddress1, - StreetAddress2 = source.AddressOnFile.StreetAddress2, - City = source.AddressOnFile.City, - State = source.AddressOnFile.State, - PostalCode = source.AddressOnFile.PostalCode - } - : source.AddressOnFile != null - ? new Address - { - StreetAddress1 = PiiMasker.MaskStreetAddress(source.AddressOnFile.StreetAddress1, source.AddressOnFile.StreetAddress2), - City = source.AddressOnFile.City, - State = source.AddressOnFile.State, - PostalCode = source.AddressOnFile.PostalCode - } - : null - }; + return HouseholdPiiFilter.Apply(core, piiVisibility); } /// diff --git a/src/SEBT.Portal.Infrastructure/Services/IdProofingService.cs b/src/SEBT.Portal.Infrastructure/Services/IdProofingService.cs index f246ce995..5f387ead3 100644 --- a/src/SEBT.Portal.Infrastructure/Services/IdProofingService.cs +++ b/src/SEBT.Portal.Infrastructure/Services/IdProofingService.cs @@ -60,17 +60,22 @@ public IdProofingDecision Evaluate( } public PiiVisibility GetVisibility(UserIalLevel userIalLevel) + { + return GetVisibility(userIalLevel, []); + } + + public PiiVisibility GetVisibility(UserIalLevel userIalLevel, IReadOnlyList cases) { return new PiiVisibility( - IncludeAddress: EvaluateView(ProtectedResource.Address, userIalLevel), - IncludeEmail: EvaluateView(ProtectedResource.Email, userIalLevel), - IncludePhone: EvaluateView(ProtectedResource.Phone, userIalLevel)); + IncludeAddress: EvaluateView(ProtectedResource.Address, userIalLevel, cases), + IncludeEmail: EvaluateView(ProtectedResource.Email, userIalLevel, cases), + IncludePhone: EvaluateView(ProtectedResource.Phone, userIalLevel, cases)); } - private bool EvaluateView(ProtectedResource resource, UserIalLevel userIalLevel) + private bool EvaluateView(ProtectedResource resource, UserIalLevel userIalLevel, IReadOnlyList cases) { var requirement = _settings.Get(resource, ProtectedAction.View); - var requiredLevel = requirement.Resolve([]); + var requiredLevel = requirement.Resolve(cases); return userIalLevel >= requiredLevel; } } diff --git a/src/SEBT.Portal.UseCases/Household/GetHouseholdData/GetHouseholdDataQueryHandler.cs b/src/SEBT.Portal.UseCases/Household/GetHouseholdData/GetHouseholdDataQueryHandler.cs index 6fe32e875..a2ef8217a 100644 --- a/src/SEBT.Portal.UseCases/Household/GetHouseholdData/GetHouseholdDataQueryHandler.cs +++ b/src/SEBT.Portal.UseCases/Household/GetHouseholdData/GetHouseholdDataQueryHandler.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using SEBT.Portal.Core.AppSettings; +using SEBT.Portal.Core.Models; using SEBT.Portal.Core.Models.Auth; using SEBT.Portal.Core.Models.Household; using SEBT.Portal.Core.Repositories; @@ -33,25 +34,23 @@ public async Task> Handle(GetHouseholdDataQuery query, Can if (identifier == null) { - logger.LogWarning("Household data request attempted but no household identifier could be resolved from claims"); + logger.LogError("Household data request attempted but no household identifier could be resolved from claims"); return Result.Unauthorized("Unable to identify user from token."); } logger.LogDebug("Household data request received for identifier type {Type}", identifier.Type); var userIalLevel = UserIalLevelExtensions.FromClaimsPrincipal(query.User); - var piiVisibility = piiVisibilityService.GetVisibility(userIalLevel); - logger.LogInformation( - "PII visibility for user (IalLevel={IalLevel}): Address={IncludeAddress}, Email={IncludeEmail}, Phone={IncludePhone}", - userIalLevel, - piiVisibility.IncludeAddress, - piiVisibility.IncludeEmail, - piiVisibility.IncludePhone); + // Fetch with full PII so per-case-type view requirements (e.g. address+view) + // can be resolved against the household's actual cases below. The + // case-aware visibility computed after the fetch decides what's masked + // before returning to the caller. + var fullPiiVisibility = new PiiVisibility(IncludeAddress: true, IncludeEmail: true, IncludePhone: true); var householdData = await repository.GetHouseholdByIdentifierAsync( identifier, - piiVisibility, + fullPiiVisibility, userIalLevel, cancellationToken); @@ -71,7 +70,7 @@ public async Task> Handle(GetHouseholdDataQuery query, Can identifier.Value, benefitIc.Trim(), verifiedDob, - piiVisibility, + fullPiiVisibility, userIalLevel, cancellationToken); if (householdData != null) @@ -106,6 +105,21 @@ public async Task> Handle(GetHouseholdDataQuery query, Can new Dictionary { ["requiredIal"] = decision.RequiredLevel.ToString() }); } + // Resolve PII visibility now that we have the household's cases — needed for + // per-case-type view requirements like address+view, which the case-less + // overload can't evaluate ("no cases = no case-derived reason to require + // elevated IAL"). Then apply masking before returning. + var piiVisibility = piiVisibilityService.GetVisibility(userIalLevel, householdData.SummerEbtCases); + + logger.LogInformation( + "PII visibility for user (IalLevel={IalLevel}): Address={IncludeAddress}, Email={IncludeEmail}, Phone={IncludePhone}", + userIalLevel, + piiVisibility.IncludeAddress, + piiVisibility.IncludeEmail, + piiVisibility.IncludePhone); + + householdData = HouseholdPiiFilter.Apply(householdData, piiVisibility); + // Classify the household on the PRE-filter state so analytics can // distinguish cohorts even after co-loaded cases are suppressed. Then // apply the suppression for the excluded cohort. diff --git a/test/SEBT.Portal.Tests/Unit/Controllers/HouseholdControllerTests.cs b/test/SEBT.Portal.Tests/Unit/Controllers/HouseholdControllerTests.cs index c808a1f0d..44c5db549 100644 --- a/test/SEBT.Portal.Tests/Unit/Controllers/HouseholdControllerTests.cs +++ b/test/SEBT.Portal.Tests/Unit/Controllers/HouseholdControllerTests.cs @@ -47,6 +47,10 @@ public HouseholdControllerTests() Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any>()) .Returns(new IdProofingDecision(IsAllowed: true, RequiredLevel: UserIalLevel.None)); + // Default: case-aware visibility returns full PII so existing tests don't need to + // wire up the cases-aware overload. Tests verifying masking behavior should override. + _piiVisibilityService.GetVisibility(Arg.Any(), Arg.Any>()) + .Returns(new PiiVisibility(IncludeAddress: true, IncludeEmail: true, IncludePhone: true)); // Default: self-service rules allow both actions _selfServiceEvaluator.Evaluate(Arg.Any()) .Returns(new AllowedActions { CanUpdateAddress = true, CanRequestReplacementCard = true }); diff --git a/test/SEBT.Portal.Tests/Unit/Services/IdProofingServiceTests.cs b/test/SEBT.Portal.Tests/Unit/Services/IdProofingServiceTests.cs index ea976bba1..9ce14e83d 100644 --- a/test/SEBT.Portal.Tests/Unit/Services/IdProofingServiceTests.cs +++ b/test/SEBT.Portal.Tests/Unit/Services/IdProofingServiceTests.cs @@ -185,4 +185,39 @@ public void GetVisibility_None_HidesAll() Assert.False(visibility.IncludeEmail); Assert.False(visibility.IncludePhone); } + + // Per-case-type "address+view" IdProofingRequirements must resolve against the user's actual cases, + // mirroring how household+view is evaluated. Without this, a per-case-type + // PII requirement silently degrades to IAL1 for every request, because the + // requirement is resolved against an empty case list. + [Fact] + public void GetVisibility_PerCaseTypeAddressView_ResolvesAgainstActualCases() + { + var settings = new IdProofingRequirementsSettings(); + settings.Requirements["address+view"] = IalRequirement.PerCaseType( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["application"] = IalLevel.IAL1plus, + ["streamline"] = IalLevel.IAL1plus, + ["coloadedStreamline"] = IalLevel.IAL1plus + }); + settings.Requirements["email+view"] = IalRequirement.Uniform(IalLevel.IAL1); + settings.Requirements["phone+view"] = IalRequirement.Uniform(IalLevel.IAL1); + + var service = CreateService(settings); + + var streamlineCase = new SummerEbtCase + { + ChildFirstName = "Test", + ChildLastName = "Child", + IsStreamlineCertified = true, + IsCoLoaded = false + }; + + var visibility = service.GetVisibility(UserIalLevel.IAL1, [streamlineCase]); + + Assert.False(visibility.IncludeAddress); + Assert.True(visibility.IncludeEmail); + Assert.True(visibility.IncludePhone); + } } diff --git a/test/SEBT.Portal.Tests/Unit/UseCases/Household/GetHouseholdDataQueryHandlerTests.cs b/test/SEBT.Portal.Tests/Unit/UseCases/Household/GetHouseholdDataQueryHandlerTests.cs index b263b93a5..bd7bdcbed 100644 --- a/test/SEBT.Portal.Tests/Unit/UseCases/Household/GetHouseholdDataQueryHandlerTests.cs +++ b/test/SEBT.Portal.Tests/Unit/UseCases/Household/GetHouseholdDataQueryHandlerTests.cs @@ -49,6 +49,11 @@ public GetHouseholdDataQueryHandlerTests() Arg.Any(), Arg.Any>()) .Returns(new IdProofingDecision(IsAllowed: true, RequiredLevel: UserIalLevel.None)); + // Default: case-aware visibility returns full PII so existing tests don't need to + // wire up the cases-aware overload. Tests verifying masking behavior should override. + _piiVisibilityService.GetVisibility(Arg.Any(), Arg.Any>()) + .Returns(new PiiVisibility(IncludeAddress: true, IncludeEmail: true, IncludePhone: true)); + // Default: self-service rules allow both actions so existing tests don't need to mock this. _selfServiceEvaluator.Evaluate(Arg.Any()) .Returns(new AllowedActions { CanUpdateAddress = true, CanRequestReplacementCard = true }); @@ -228,10 +233,8 @@ public async Task Handle_WhenIdentifierResolvedAndHouseholdExistsAndIdVerified_R AddressOnFile = new Address { StreetAddress1 = "123 Main St", City = "Denver", State = "CO", PostalCode = "80202" } }; - var piiVisibility = new PiiVisibility(IncludeAddress: true, IncludeEmail: true, IncludePhone: true); _resolver.ResolveAsync(Arg.Any(), Arg.Any()) .Returns(identifier); - _piiVisibilityService.GetVisibility(UserIalLevel.IAL1plus).Returns(piiVisibility); _repository.GetHouseholdByIdentifierAsync(identifier, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(householdData); @@ -244,8 +247,7 @@ public async Task Handle_WhenIdentifierResolvedAndHouseholdExistsAndIdVerified_R // Assert Assert.True(result.IsSuccess); var successResult = Assert.IsType>(result); - Assert.Same(householdData, successResult.Value); - Assert.NotNull(successResult.Value.AddressOnFile); + Assert.Equal("123 Main St", successResult.Value.AddressOnFile?.StreetAddress1); await _repository.Received(1).GetHouseholdByIdentifierAsync( Arg.Is(id => id.Type == PreferredHouseholdIdType.Email && id.Value == EmailNormalizer.Normalize(email)), Arg.Any(), @@ -256,16 +258,20 @@ await _repository.Received(1).GetHouseholdByIdentifierAsync( [Fact] public async Task Handle_WhenIdentifierResolvedAndHouseholdExistsButNotIdVerified_ReturnsSuccessWithoutAddress() { - // Arrange + // Arrange: case-aware visibility resolves to address-hidden for this user. var email = "user@example.com"; var user = CreateUser(email, UserIalLevel.None); var identifier = HouseholdIdentifier.Email(EmailNormalizer.Normalize(email)); - var householdData = new HouseholdData { Email = email }; + var householdData = new HouseholdData + { + Email = email, + AddressOnFile = new Address { StreetAddress1 = "123 Main St", City = "Denver", State = "CO", PostalCode = "80202" } + }; - var piiVisibility = new PiiVisibility(IncludeAddress: false, IncludeEmail: true, IncludePhone: true); _resolver.ResolveAsync(Arg.Any(), Arg.Any()) .Returns(identifier); - _piiVisibilityService.GetVisibility(UserIalLevel.None).Returns(piiVisibility); + _piiVisibilityService.GetVisibility(UserIalLevel.None, Arg.Any>()) + .Returns(new PiiVisibility(IncludeAddress: false, IncludeEmail: true, IncludePhone: true)); _repository.GetHouseholdByIdentifierAsync(identifier, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(householdData); @@ -278,7 +284,7 @@ public async Task Handle_WhenIdentifierResolvedAndHouseholdExistsButNotIdVerifie // Assert Assert.True(result.IsSuccess); var successResult = Assert.IsType>(result); - Assert.Same(householdData, successResult.Value); + Assert.Equal("****", successResult.Value.AddressOnFile?.StreetAddress1); await _repository.Received(1).GetHouseholdByIdentifierAsync( Arg.Any(), Arg.Any(), @@ -441,8 +447,6 @@ public async Task Handle_WhenUserIalMeetsMinimum_ReturnsSuccess() _resolver.ResolveAsync(Arg.Any(), Arg.Any()) .Returns(identifier); - _piiVisibilityService.GetVisibility(UserIalLevel.IAL1plus) - .Returns(new PiiVisibility(IncludeAddress: true, IncludeEmail: true, IncludePhone: true)); _repository.GetHouseholdByIdentifierAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(householdData); _idProofingService.Evaluate( @@ -459,7 +463,7 @@ public async Task Handle_WhenUserIalMeetsMinimum_ReturnsSuccess() // Assert Assert.True(result.IsSuccess); var success = Assert.IsType>(result); - Assert.Same(householdData, success.Value); + Assert.Equal(email, success.Value.Email); } [Fact]