fix(uplifts): enforce request permission at service boundary

This commit is contained in:
Alexandre Brandizzi 2026-09-22 23:09:47 -03:00
parent b36b69df83
commit 9156107e72
3 changed files with 122 additions and 0 deletions

View file

@ -4,6 +4,7 @@ using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
@ -19,6 +20,8 @@ namespace SeaHaven.Services.Implementation
private readonly IWorkOrderDetailDataService _detailData;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly IUserDataService _userData;
private readonly ITeamPermissionOverrideDataService _permissionOverrideData;
private readonly ITeamPermissionPolicy _permissionPolicy;
private readonly TimeProvider _timeProvider;
private readonly ApprovalsOptions _approvalsOptions;
@ -28,6 +31,8 @@ namespace SeaHaven.Services.Implementation
IWorkOrderDetailDataService detailData,
IWorkOrderAccountResolver accountResolver,
IUserDataService userData,
ITeamPermissionOverrideDataService permissionOverrideData,
ITeamPermissionPolicy permissionPolicy,
TimeProvider timeProvider,
IOptions<ApprovalsOptions> approvalsOptions)
{
@ -36,6 +41,8 @@ namespace SeaHaven.Services.Implementation
_detailData = detailData;
_accountResolver = accountResolver;
_userData = userData;
_permissionOverrideData = permissionOverrideData;
_permissionPolicy = permissionPolicy;
_timeProvider = timeProvider;
_approvalsOptions = approvalsOptions.Value;
}
@ -61,6 +68,8 @@ namespace SeaHaven.Services.Implementation
if (!await HasWorkOrderAccessAsync(workOrderId, user, cancellationToken))
return null;
await EnsureCanRequestUpliftAsync(user, cancellationToken);
if (request.Amount <= 0)
throw new InvalidOperationException("Uplift amount must be greater than zero");
@ -82,6 +91,28 @@ namespace SeaHaven.Services.Implementation
cancellationToken);
}
private async Task EnsureCanRequestUpliftAsync(
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
var permissionUser = string.IsNullOrWhiteSpace(userId)
? null
: await _permissionOverrideData.GetUserAsync(userId, cancellationToken);
var roleName = user.IsInRole("Admin")
? "Admin"
: user.FindFirstValue(ClaimTypes.Role);
if (!_permissionPolicy.IsAllowed(
roleName,
TeamPermissionKeys.RequestUplifts,
permissionUser?.Overrides))
{
throw new UpliftForbiddenException(
"Your role can't request uplifts on this work order.");
}
}
private async Task<WorkOrderUpliftDto?> CreateLockedAsync(
int workOrderId,
decimal amount,

View file

@ -203,6 +203,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(mutationData, boardService, audit, uplifts, new PassThroughUpliftData());
@ -264,6 +266,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(
@ -332,6 +336,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(createContext),
WorkOrderAccountTestHelpers.Resolver(createContext),
new UserDataService(createContext),
new TeamPermissionOverrideDataService(createContext),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var boardData = new WorkOrderBoardDataService(cancelContext);
@ -345,6 +351,8 @@ public class WorkOrderBoardCancelServiceTests
new WorkOrderDetailDataService(cancelContext),
WorkOrderAccountTestHelpers.Resolver(cancelContext),
new UserDataService(cancelContext),
new TeamPermissionOverrideDataService(cancelContext),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(new ApprovalsOptions()));
var cancel = new WorkOrderBoardCancelService(

View file

@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
@ -37,6 +38,8 @@ public sealed class WorkOrderUpliftServiceTests
new WorkOrderDetailDataService(context),
WorkOrderAccountTestHelpers.Resolver(context),
new UserDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
TimeProvider.System,
Options.Create(NewOptions()));
}
@ -123,6 +126,86 @@ public sealed class WorkOrderUpliftServiceTests
Assert.Equal(1400m, context.Dispatches.Single(d => d.Id == 10).NTEAmount);
}
[Fact]
public async Task CreateAsync_Admin_RemainsUnrestricted()
{
await using var context = CreateContext();
var (workOrder, _) = await SeedWorkOrderAsync(context);
var service = NewService(context);
var created = await service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
Dispatcher(),
CancellationToken.None);
Assert.NotNull(created);
}
[Theory]
[InlineData("Dispatcher", true)]
[InlineData("Scheduler", false)]
public async Task CreateAsync_UsesRoleDefaultForRequestPermission(string role, bool allowed)
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
var service = NewService(context);
var act = () => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role),
CancellationToken.None);
if (allowed)
{
Assert.NotNull(await act());
return;
}
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(act);
Assert.Equal("Your role can't request uplifts on this work order.", exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Theory]
[InlineData("Scheduler", UserPermissionState.Allow, true)]
[InlineData("Dispatcher", UserPermissionState.Deny, false)]
public async Task CreateAsync_AppliesExplicitRequestPermissionOverride(
string role,
UserPermissionState overrideState,
bool allowed)
{
await using var context = CreateContext();
var (workOrder, dispatch) = await SeedWorkOrderAsync(context);
context.UserPermissionOverrides.Add(new UserPermissionOverride
{
UserId = "dispatcher-1",
PermissionKey = TeamPermissionKeys.RequestUplifts,
State = overrideState,
});
await context.SaveChangesAsync();
var service = NewService(context);
var act = () => service.CreateAsync(
workOrder.Id,
new CreateWorkOrderUpliftRequestDto { Amount = 400m },
WorkOrderAccountTestHelpers.AccountUser("dispatcher-1", 1, role),
CancellationToken.None);
if (allowed)
{
Assert.NotNull(await act());
return;
}
var exception = await Assert.ThrowsAsync<UpliftForbiddenException>(act);
Assert.Equal("Your role can't request uplifts on this work order.", exception.Message);
Assert.Empty(context.DispatchUpliftRequests);
Assert.Equal(1000m, dispatch.NTEAmount);
}
[Fact]
public async Task CreateAsync_PmAmountAboveCap_CreatesPendingRequest()
{