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]