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]