From 9156107e725f961fb7408dea4499ac1119b31257 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Tue, 22 Sep 2026 23:09:47 -0300 Subject: [PATCH] fix(uplifts): enforce request permission at service boundary --- .../Implementation/WorkOrderUpliftService.cs | 31 +++++++ .../WorkOrderBoardCancelServiceTests.cs | 8 ++ .../WorkOrderUpliftServiceTests.cs | 83 +++++++++++++++++++ 3 files changed, 122 insertions(+) diff --git a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs index 6a2c388..261a8c5 100644 --- a/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderUpliftService.cs @@ -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) { @@ -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 CreateLockedAsync( int workOrderId, decimal amount, diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs index e797a68..c910604 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCancelServiceTests.cs @@ -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( diff --git a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs index 7ec8856..bb63d73 100644 --- a/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderUpliftServiceTests.cs @@ -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(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(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() {