Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/SEBT.Portal.Api/appsettings.co.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
"Enabled": false
},
"IdProofingRequirements": {
"address+view": "IAL1",
"address+view": "IAL1plus",
"address+write": "IAL1plus",
"email+view": "IAL1",
"phone+view": "IAL1",
Expand Down
15 changes: 15 additions & 0 deletions src/SEBT.Portal.Core/Services/IPiiVisibilityService.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -10,5 +11,19 @@ namespace SEBT.Portal.Core.Services;
/// </summary>
public interface IPiiVisibilityService
{
/// <summary>
/// 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.
/// </summary>
PiiVisibility GetVisibility(UserIalLevel userIalLevel);

/// <summary>
/// Resolves visibility against the user's actual cases. Required for
/// per-case-type view requirements (e.g. <c>address+view</c>) to apply
/// correctly — without the cases, the "highest wins" rule has nothing
/// to resolve against.
/// </summary>
PiiVisibility GetVisibility(UserIalLevel userIalLevel, IReadOnlyList<SummerEbtCase> cases);
}
42 changes: 42 additions & 0 deletions src/SEBT.Portal.Core/Utilities/HouseholdPiiFilter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using SEBT.Portal.Core.Models;
using SEBT.Portal.Core.Models.Household;

namespace SEBT.Portal.Core.Utilities;

/// <summary>
/// Applies a <see cref="PiiVisibility"/> to a <see cref="HouseholdData"/>,
/// 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.
/// </summary>
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
};
}
}
31 changes: 2 additions & 29 deletions src/SEBT.Portal.Infrastructure/Repositories/HouseholdRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public HouseholdRepository(
{
return null;
}
return ApplyPiiVisibility(core, piiVisibility);
return HouseholdPiiFilter.Apply(core, piiVisibility);
}

private static PluginHouseholdIdentifierType? MapToPluginIdentifierType(PreferredHouseholdIdType type)
Expand Down Expand Up @@ -192,34 +192,7 @@ public Task<bool> 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);
}

/// <inheritdoc />
Expand Down
15 changes: 10 additions & 5 deletions src/SEBT.Portal.Infrastructure/Services/IdProofingService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,22 @@ public IdProofingDecision Evaluate(
}

public PiiVisibility GetVisibility(UserIalLevel userIalLevel)
{
return GetVisibility(userIalLevel, []);
}

public PiiVisibility GetVisibility(UserIalLevel userIalLevel, IReadOnlyList<SummerEbtCase> 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<SummerEbtCase> cases)
{
var requirement = _settings.Get(resource, ProtectedAction.View);
var requiredLevel = requirement.Resolve([]);
var requiredLevel = requirement.Resolve(cases);
return userIalLevel >= requiredLevel;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -33,25 +34,23 @@ public async Task<Result<HouseholdData>> 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<HouseholdData>.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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small/nit sort of thing, but this feels like a special case deserving of a singleton instance or a factory (i.e., PiiVisibility.Full or PiiVisibility.Full())


var householdData = await repository.GetHouseholdByIdentifierAsync(
identifier,
piiVisibility,
fullPiiVisibility,
userIalLevel,
cancellationToken);

Expand All @@ -71,7 +70,7 @@ public async Task<Result<HouseholdData>> Handle(GetHouseholdDataQuery query, Can
identifier.Value,
benefitIc.Trim(),
verifiedDob,
piiVisibility,
fullPiiVisibility,
userIalLevel,
cancellationToken);
if (householdData != null)
Expand Down Expand Up @@ -106,6 +105,21 @@ public async Task<Result<HouseholdData>> Handle(GetHouseholdDataQuery query, Can
new Dictionary<string, object?> { ["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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ public HouseholdControllerTests()
Arg.Any<ProtectedResource>(), Arg.Any<ProtectedAction>(),
Arg.Any<UserIalLevel>(), Arg.Any<IReadOnlyList<SummerEbtCase>>())
.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<UserIalLevel>(), Arg.Any<IReadOnlyList<SummerEbtCase>>())
.Returns(new PiiVisibility(IncludeAddress: true, IncludeEmail: true, IncludePhone: true));
// Default: self-service rules allow both actions
_selfServiceEvaluator.Evaluate(Arg.Any<SummerEbtCase>())
.Returns(new AllowedActions { CanUpdateAddress = true, CanRequestReplacementCard = true });
Expand Down
35 changes: 35 additions & 0 deletions test/SEBT.Portal.Tests/Unit/Services/IdProofingServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, IalLevel>(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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ public GetHouseholdDataQueryHandlerTests()
Arg.Any<UserIalLevel>(), Arg.Any<IReadOnlyList<SummerEbtCase>>())
.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<UserIalLevel>(), Arg.Any<IReadOnlyList<SummerEbtCase>>())
.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<SummerEbtCase>())
.Returns(new AllowedActions { CanUpdateAddress = true, CanRequestReplacementCard = true });
Expand Down Expand Up @@ -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<ClaimsPrincipal>(), Arg.Any<CancellationToken>())
.Returns(identifier);
_piiVisibilityService.GetVisibility(UserIalLevel.IAL1plus).Returns(piiVisibility);
_repository.GetHouseholdByIdentifierAsync(identifier, Arg.Any<PiiVisibility>(), Arg.Any<UserIalLevel>(), Arg.Any<CancellationToken>())
.Returns(householdData);

Expand All @@ -244,8 +247,7 @@ public async Task Handle_WhenIdentifierResolvedAndHouseholdExistsAndIdVerified_R
// Assert
Assert.True(result.IsSuccess);
var successResult = Assert.IsType<SuccessResult<HouseholdData>>(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<HouseholdIdentifier>(id => id.Type == PreferredHouseholdIdType.Email && id.Value == EmailNormalizer.Normalize(email)),
Arg.Any<PiiVisibility>(),
Expand All @@ -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<ClaimsPrincipal>(), Arg.Any<CancellationToken>())
.Returns(identifier);
_piiVisibilityService.GetVisibility(UserIalLevel.None).Returns(piiVisibility);
_piiVisibilityService.GetVisibility(UserIalLevel.None, Arg.Any<IReadOnlyList<SummerEbtCase>>())
.Returns(new PiiVisibility(IncludeAddress: false, IncludeEmail: true, IncludePhone: true));
_repository.GetHouseholdByIdentifierAsync(identifier, Arg.Any<PiiVisibility>(), Arg.Any<UserIalLevel>(), Arg.Any<CancellationToken>())
.Returns(householdData);

Expand All @@ -278,7 +284,7 @@ public async Task Handle_WhenIdentifierResolvedAndHouseholdExistsButNotIdVerifie
// Assert
Assert.True(result.IsSuccess);
var successResult = Assert.IsType<SuccessResult<HouseholdData>>(result);
Assert.Same(householdData, successResult.Value);
Assert.Equal("****", successResult.Value.AddressOnFile?.StreetAddress1);
await _repository.Received(1).GetHouseholdByIdentifierAsync(
Arg.Any<HouseholdIdentifier>(),
Arg.Any<PiiVisibility>(),
Expand Down Expand Up @@ -441,8 +447,6 @@ public async Task Handle_WhenUserIalMeetsMinimum_ReturnsSuccess()

_resolver.ResolveAsync(Arg.Any<ClaimsPrincipal>(), Arg.Any<CancellationToken>())
.Returns(identifier);
_piiVisibilityService.GetVisibility(UserIalLevel.IAL1plus)
.Returns(new PiiVisibility(IncludeAddress: true, IncludeEmail: true, IncludePhone: true));
_repository.GetHouseholdByIdentifierAsync(Arg.Any<HouseholdIdentifier>(), Arg.Any<PiiVisibility>(), Arg.Any<UserIalLevel>(), Arg.Any<CancellationToken>())
.Returns(householdData);
_idProofingService.Evaluate(
Expand All @@ -459,7 +463,7 @@ public async Task Handle_WhenUserIalMeetsMinimum_ReturnsSuccess()
// Assert
Assert.True(result.IsSuccess);
var success = Assert.IsType<SuccessResult<HouseholdData>>(result);
Assert.Same(householdData, success.Value);
Assert.Equal(email, success.Value.Email);
}

[Fact]
Expand Down
Loading