fix(work-orders): authorize GET media and forward cancellation

Enforce claims-derived read scope on media list and thread CancellationToken through detail data reads so HTTP cancel stops EF work.
This commit is contained in:
Arthur Bassi 2026-08-04 14:14:37 -03:00
parent 680012d88b
commit 6b18327d6b
7 changed files with 158 additions and 23 deletions

View file

@ -31,10 +31,23 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpGet("{id:int}/media")]
public async Task<IActionResult> GetMedia(int id, CancellationToken cancellationToken)
{
var media = await _workOrderMediaService.GetMediaAsync(id, cancellationToken);
if (media == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(media);
try
{
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var media = await _workOrderMediaService.GetMediaAsync(id, User, actorId, cancellationToken);
if (media == null)
return NotFound(new Response { Status = "Error", Message = "Work order not found." });
return Ok(media);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden,
new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
}
[HttpPost("{id:int}/media")]

View file

@ -14,9 +14,9 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public Task<bool> ExistsAsync(int workOrderId)
public Task<bool> ExistsAsync(int workOrderId, CancellationToken cancellationToken = default)
=> WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
.AnyAsync(w => w.Id == workOrderId);
.AnyAsync(w => w.Id == workOrderId, cancellationToken);
public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
{
@ -63,13 +63,15 @@ namespace SeaHaven.DataServices.Implementation
return await query.ToListAsync();
}
public async Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId)
public async Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(
int workOrderId,
CancellationToken cancellationToken = default)
{
return await _context.workOrderAttachments
.AsNoTracking()
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true)
.OrderByDescending(a => a.CreatedDate)
.ToListAsync();
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId)
@ -86,10 +88,12 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync();
}
public async Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId)
public async Task<WorkOrder?> GetWorkOrderForMediaAsync(
int workOrderId,
CancellationToken cancellationToken = default)
{
return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
.FirstOrDefaultAsync(w => w.Id == workOrderId);
.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
}
}
}

View file

@ -6,13 +6,17 @@ namespace SeaHaven.DataServices.Interfaces
{
public interface IWorkOrderDetailDataService
{
Task<bool> ExistsAsync(int workOrderId);
Task<bool> ExistsAsync(int workOrderId, CancellationToken cancellationToken = default);
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null);
Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(int workOrderId);
Task<IReadOnlyList<WorkOrderAttachments>> GetAttachmentsAsync(
int workOrderId,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId);
Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId);
Task<WorkOrder?> GetWorkOrderForMediaAsync(
int workOrderId,
CancellationToken cancellationToken = default);
}
public interface ICompletionDocTemplateDataService

View file

@ -5,7 +5,7 @@ using SeaHaven.Services.Exceptions;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Claims-derived authorization for work-order media mutations.
/// Claims-derived authorization for work-order media read/mutations.
/// True multi-tenant CustomerId/TenantId is not modeled on WorkOrder/JWT;
/// scope is role + Assigned (<see cref="WorkOrder.AssignTo"/>) for Technician.
/// </summary>
@ -19,6 +19,16 @@ namespace SeaHaven.Services.Helpers
"Supervisor"
};
public static void EnsureCanRead(ClaimsPrincipal user, string? actorId)
{
EnsureAuthenticated(user, actorId, "You are not allowed to view work order media.");
if (IsStaff(user) || user.IsInRole("User"))
return;
throw Forbidden("You are not allowed to view work order media.");
}
public static void EnsureCanMutate(ClaimsPrincipal user, string? actorId)
{
EnsureAuthenticated(user, actorId);
@ -61,22 +71,25 @@ namespace SeaHaven.Services.Helpers
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
private static void EnsureAuthenticated(ClaimsPrincipal user, string? actorId)
private static void EnsureAuthenticated(
ClaimsPrincipal user,
string? actorId,
string? forbiddenMessage = null)
{
if (user is null
|| !(user.Identity?.IsAuthenticated ?? false)
|| string.IsNullOrWhiteSpace(actorId))
{
throw Forbidden();
throw Forbidden(forbiddenMessage);
}
}
private static bool IsStaff(ClaimsPrincipal user)
=> StaffRoles.Any(user.IsInRole);
private static WorkOrderBoardValidationException Forbidden()
private static WorkOrderBoardValidationException Forbidden(string? message = null)
=> new(
"Forbidden",
"You are not allowed to mutate work order media.");
message ?? "You are not allowed to mutate work order media.");
}
}

View file

@ -28,16 +28,22 @@ namespace SeaHaven.Services.Implementation
public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
int workOrderId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default)
{
if (!await _detailData.ExistsAsync(workOrderId))
WorkOrderMediaAuthorization.EnsureCanRead(user, actorId);
if (!await _detailData.ExistsAsync(workOrderId, cancellationToken))
return null;
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId);
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId, cancellationToken);
if (workOrder == null)
return null;
var attachments = await _detailData.GetAttachmentsAsync(workOrderId);
WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId!, workOrder);
var attachments = await _detailData.GetAttachmentsAsync(workOrderId, cancellationToken);
return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
}

View file

@ -6,7 +6,11 @@ namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderMediaService
{
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(int workOrderId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
int workOrderId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
/// <summary>
/// Authorizes the caller and validates the work order is mutable before any blob storage write.

View file

@ -631,7 +631,7 @@ public class WorkOrderMediaServiceTests
});
await context.SaveChangesAsync();
var media = await service.GetMediaAsync(1);
var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1");
Assert.NotNull(media);
Assert.Equal(3, media!.Count);
@ -640,6 +640,97 @@ public class WorkOrderMediaServiceTests
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Extra);
}
[Fact]
public async Task GetMedia_TechnicianOnAssignedWorkOrder_Succeeds()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AssignTo = "tech-1",
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var media = await service.GetMediaAsync(
1,
AuthenticatedUser("tech-1", "User"),
"tech-1");
Assert.NotNull(media);
Assert.Contains(media!, m => m.Category == WorkOrderMediaCategory.Before);
}
[Fact]
public async Task GetMedia_TechnicianOnUnassignedWorkOrder_ThrowsNotFound()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AssignTo = "other-tech",
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetMediaAsync(1, AuthenticatedUser("tech-1", "User"), "tech-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task GetMedia_CallerWithoutRole_ThrowsForbidden()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetMediaAsync(1, AuthenticatedWithoutRole(), "actor-1"));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task GetMedia_UnauthenticatedCaller_ThrowsForbidden()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetMediaAsync(1, UnauthenticatedUser(), "actor-1"));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task GetMedia_CanceledToken_ThrowsOperationCanceled()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
BeforPhotoAttachment = "https://example.com/before.jpg"
});
await context.SaveChangesAsync();
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
service.GetMediaAsync(1, AuthenticatedUser(), "actor-1", cts.Token));
}
[Fact]
public async Task DeleteMedia_ReadOnlyWorkOrder_Throws()
{