Skip to content

Commit 7c1f058

Browse files
author
James Blair
committed
security: enforce minimum IAL gate on address update and card replacement
Closes authorization bypass where a user blocked from viewing household data (403 on GET /household/data) could still modify it via PUT /household/address or POST /household/cards/replace. Both handlers now inject IMinimumIalService and check the user's IAL against the case-derived minimum before proceeding with write operations. Also adds ForbiddenResult handling to MvcResultExtensions so any handler returning ForbiddenResult gets a consistent 403 ProblemDetails response.
1 parent 24dcb32 commit 7c1f058

6 files changed

Lines changed: 74 additions & 2 deletions

File tree

src/SEBT.Portal.Kernel.AspNetCore/MvcResultExtensions.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ UnauthorizedResult when useProblemDetails
6767
=> result.ToProblemDetailsResult(HttpStatusCode.Forbidden),
6868
UnauthorizedResult when !useProblemDetails
6969
=> new ForbidResult(),
70+
ForbiddenResult forbidden when useProblemDetails
71+
=> forbidden.ToProblemDetailsResult(HttpStatusCode.Forbidden),
72+
ForbiddenResult when !useProblemDetails
73+
=> new StatusCodeResult((int)HttpStatusCode.Forbidden),
7074
DependencyFailedResult when useProblemDetails
7175
=> result.ToProblemDetailsResult(HttpStatusCode.BadGateway),
7276
DependencyFailedResult when !useProblemDetails
@@ -142,6 +146,10 @@ UnauthorizedResult<T> when useProblemDetails
142146
=> result.ToProblemDetailsResult(HttpStatusCode.Forbidden),
143147
UnauthorizedResult<T> when !useProblemDetails
144148
=> new ForbidResult(),
149+
ForbiddenResult<T> forbidden when useProblemDetails
150+
=> forbidden.ToProblemDetailsWithExtensionsResult(HttpStatusCode.Forbidden),
151+
ForbiddenResult<T> when !useProblemDetails
152+
=> new StatusCodeResult((int)HttpStatusCode.Forbidden),
145153
DependencyFailedResult<T> when useProblemDetails
146154
=> result.ToProblemDetailsResult(HttpStatusCode.BadGateway),
147155
DependencyFailedResult<T> when !useProblemDetails
@@ -168,4 +176,23 @@ public static IActionResult ToProblemDetailsResult(this Result result, HttpStatu
168176
{
169177
StatusCode = (int)statusCode,
170178
};
179+
180+
/// <summary>
181+
/// Converts a <see cref="ForbiddenResult{T}"/> into a <see cref="ObjectResult"/> containing
182+
/// <see cref="ProblemDetails"/> with the result's extensions merged in.
183+
/// </summary>
184+
private static IActionResult ToProblemDetailsWithExtensionsResult<T>(this ForbiddenResult<T> result, HttpStatusCode statusCode)
185+
{
186+
var problemDetails = new ProblemDetails
187+
{
188+
Title = "Insufficient identity assurance level",
189+
Detail = result.Message,
190+
Status = (int)statusCode,
191+
};
192+
foreach (var (key, value) in result.Extensions)
193+
{
194+
problemDetails.Extensions[key] = value;
195+
}
196+
return new ObjectResult(problemDetails) { StatusCode = (int)statusCode };
197+
}
171198
}

src/SEBT.Portal.UseCases/Household/RequestCardReplacement/RequestCardReplacementCommandHandler.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public class RequestCardReplacementCommandHandler(
1717
IValidator<RequestCardReplacementCommand> validator,
1818
IHouseholdIdentifierResolver resolver,
1919
IHouseholdRepository repository,
20+
IMinimumIalService minimumIalService,
2021
TimeProvider timeProvider,
2122
ILogger<RequestCardReplacementCommandHandler> logger)
2223
: ICommandHandler<RequestCardReplacementCommand>
@@ -56,6 +57,19 @@ public async Task<Result> Handle(
5657
return Result.PreconditionFailed(PreconditionFailedReason.NotFound, "Household data not found.");
5758
}
5859

60+
// SECURITY: Block write operations when the user has not met the minimum IAL
61+
// required by their cases. See docs/tdd/minimum-ial-determination.md.
62+
var minimumIal = minimumIalService.GetMinimumIal(household.SummerEbtCases);
63+
if (userIalLevel < minimumIal)
64+
{
65+
logger.LogInformation(
66+
"Card replacement denied: user IAL {UserIal} is below minimum {MinimumIal}",
67+
userIalLevel,
68+
minimumIal);
69+
return Result.Forbidden(
70+
$"This household requires {minimumIal}. Complete identity verification to request card replacements.");
71+
}
72+
5973
var cooldownErrors = CheckCooldown(command.CaseIds, household, timeProvider);
6074
if (cooldownErrors.Count > 0)
6175
{

src/SEBT.Portal.UseCases/Household/UpdateAddress/UpdateAddressCommandHandler.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public class UpdateAddressCommandHandler(
2626
IHouseholdIdentifierResolver resolver,
2727
IHouseholdRepository householdRepository,
2828
IIdProofingRequirementsService idProofingRequirementsService,
29+
IMinimumIalService minimumIalService,
2930
IStateAddressUpdateService stateAddressUpdateService,
3031
ILogger<UpdateAddressCommandHandler> logger)
3132
: ICommandHandler<UpdateAddressCommand, AddressValidationResult>
@@ -110,6 +111,23 @@ public async Task<Result<AddressValidationResult>> Handle(UpdateAddressCommand c
110111
var household = await householdRepository.GetHouseholdByIdentifierAsync(
111112
identifier, piiVisibility, userIalLevel, cancellationToken);
112113

114+
if (household != null)
115+
{
116+
// SECURITY: Block write operations when the user has not met the minimum IAL
117+
// required by their cases. See docs/tdd/minimum-ial-determination.md.
118+
var minimumIal = minimumIalService.GetMinimumIal(household.SummerEbtCases);
119+
if (userIalLevel < minimumIal)
120+
{
121+
logger.LogInformation(
122+
"Address update denied: user IAL {UserIal} is below minimum {MinimumIal}",
123+
userIalLevel,
124+
minimumIal);
125+
return Result<AddressValidationResult>.Forbidden(
126+
$"This household requires {minimumIal}. Complete identity verification to update your address.",
127+
new Dictionary<string, object?> { ["requiredIal"] = minimumIal.ToString() });
128+
}
129+
}
130+
113131
if (household is { BenefitIssuanceType: BenefitIssuanceType.SnapEbtCard or BenefitIssuanceType.TanfEbtCard })
114132
{
115133
logger.LogWarning(

test/SEBT.Portal.Tests/Unit/UseCases/DependenciesTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ private static ServiceProvider BuildProviderWithUseCases()
2323
// Stub infrastructure dependencies that handlers need at construction time
2424
services.AddSingleton(Substitute.For<IHouseholdIdentifierResolver>());
2525
services.AddSingleton(Substitute.For<IHouseholdRepository>());
26+
services.AddSingleton(Substitute.For<IMinimumIalService>());
2627
services.AddSingleton(TimeProvider.System);
2728
services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
2829

test/SEBT.Portal.Tests/Unit/UseCases/Household/RequestCardReplacementCommandHandlerTests.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,19 @@ public class RequestCardReplacementCommandHandlerTests
2222
Substitute.For<IHouseholdIdentifierResolver>();
2323
private readonly IHouseholdRepository _repository =
2424
Substitute.For<IHouseholdRepository>();
25+
private readonly IMinimumIalService _minimumIalService =
26+
Substitute.For<IMinimumIalService>();
2527
private readonly NullLogger<RequestCardReplacementCommandHandler> _logger =
2628
NullLogger<RequestCardReplacementCommandHandler>.Instance;
2729

30+
public RequestCardReplacementCommandHandlerTests()
31+
{
32+
// Default: IAL gate passes (no elevated requirement)
33+
_minimumIalService.GetMinimumIal(Arg.Any<IReadOnlyList<SummerEbtCase>>()).Returns(UserIalLevel.None);
34+
}
35+
2836
private RequestCardReplacementCommandHandler CreateHandler(TimeProvider? timeProvider = null) =>
29-
new(_validator, _resolver, _repository, timeProvider ?? TimeProvider.System, _logger);
37+
new(_validator, _resolver, _repository, _minimumIalService, timeProvider ?? TimeProvider.System, _logger);
3038

3139
private static ClaimsPrincipal CreateUser(string email, string? ialClaim = null)
3240
{

test/SEBT.Portal.Tests/Unit/UseCases/Household/UpdateAddressCommandHandlerTests.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ public class UpdateAddressCommandHandlerTests
3636
Substitute.For<IIdProofingRequirementsService>();
3737
private readonly IStateAddressUpdateService _stateAddressUpdateService =
3838
Substitute.For<IStateAddressUpdateService>();
39+
private readonly IMinimumIalService _minimumIalService =
40+
Substitute.For<IMinimumIalService>();
3941
private readonly NullLogger<UpdateAddressCommandHandler> _logger =
4042
NullLogger<UpdateAddressCommandHandler>.Instance;
4143

@@ -64,11 +66,13 @@ public UpdateAddressCommandHandlerTests()
6466
.Returns(AddressUpdateResult.Success());
6567
_idProofingRequirementsService.GetPiiVisibility(Arg.Any<UserIalLevel>())
6668
.Returns(new PiiVisibility(false, false, false));
69+
// Default: IAL gate passes (no elevated requirement)
70+
_minimumIalService.GetMinimumIal(Arg.Any<IReadOnlyList<SummerEbtCase>>()).Returns(UserIalLevel.None);
6771
}
6872

6973
private UpdateAddressCommandHandler CreateHandler() =>
7074
new(_validator, _addressUpdateService, _addressValidationService, _resolver, _householdRepository,
71-
_idProofingRequirementsService, _stateAddressUpdateService, _logger);
75+
_idProofingRequirementsService, _minimumIalService, _stateAddressUpdateService, _logger);
7276

7377
private static ClaimsPrincipal CreateUser(string email)
7478
{

0 commit comments

Comments
 (0)