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")] [HttpGet("{id:int}/media")]
public async Task<IActionResult> GetMedia(int id, CancellationToken cancellationToken) public async Task<IActionResult> GetMedia(int id, CancellationToken cancellationToken)
{ {
var media = await _workOrderMediaService.GetMediaAsync(id, cancellationToken); try
if (media == null) {
return NotFound(new Response { Status = "Error", Message = "Work order not found." }); var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(media); 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")] [HttpPost("{id:int}/media")]

View file

@ -14,9 +14,9 @@ namespace SeaHaven.DataServices.Implementation
_context = context; _context = context;
} }
public Task<bool> ExistsAsync(int workOrderId) public Task<bool> ExistsAsync(int workOrderId, CancellationToken cancellationToken = default)
=> WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) => WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking())
.AnyAsync(w => w.Id == workOrderId); .AnyAsync(w => w.Id == workOrderId, cancellationToken);
public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId) public async Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId)
{ {
@ -63,13 +63,15 @@ namespace SeaHaven.DataServices.Implementation
return await query.ToListAsync(); 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 return await _context.workOrderAttachments
.AsNoTracking() .AsNoTracking()
.Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true) .Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true)
.OrderByDescending(a => a.CreatedDate) .OrderByDescending(a => a.CreatedDate)
.ToListAsync(); .ToListAsync(cancellationToken);
} }
public async Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId) public async Task<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId)
@ -86,10 +88,12 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync(); .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()) 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 public interface IWorkOrderDetailDataService
{ {
Task<bool> ExistsAsync(int workOrderId); Task<bool> ExistsAsync(int workOrderId, CancellationToken cancellationToken = default);
Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId); Task<WorkOrderDetailExtendedFields?> GetExtendedFieldsAsync(int workOrderId);
Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId); Task<IReadOnlyList<Comments>> GetCommentsAsync(int workOrderId);
Task<IReadOnlyList<WorkOrderAuditLog>> GetAuditLogsAsync(int workOrderId, int? limit = null); 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<IReadOnlyList<DispatchSignoffRow>> GetDispatchSignoffsAsync(int workOrderId);
Task<WorkOrder?> GetWorkOrderForMediaAsync(int workOrderId); Task<WorkOrder?> GetWorkOrderForMediaAsync(
int workOrderId,
CancellationToken cancellationToken = default);
} }
public interface ICompletionDocTemplateDataService public interface ICompletionDocTemplateDataService

View file

@ -5,7 +5,7 @@ using SeaHaven.Services.Exceptions;
namespace SeaHaven.Services.Helpers namespace SeaHaven.Services.Helpers
{ {
/// <summary> /// <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; /// True multi-tenant CustomerId/TenantId is not modeled on WorkOrder/JWT;
/// scope is role + Assigned (<see cref="WorkOrder.AssignTo"/>) for Technician. /// scope is role + Assigned (<see cref="WorkOrder.AssignTo"/>) for Technician.
/// </summary> /// </summary>
@ -19,6 +19,16 @@ namespace SeaHaven.Services.Helpers
"Supervisor" "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) public static void EnsureCanMutate(ClaimsPrincipal user, string? actorId)
{ {
EnsureAuthenticated(user, actorId); EnsureAuthenticated(user, actorId);
@ -61,22 +71,25 @@ namespace SeaHaven.Services.Helpers
throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); 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 if (user is null
|| !(user.Identity?.IsAuthenticated ?? false) || !(user.Identity?.IsAuthenticated ?? false)
|| string.IsNullOrWhiteSpace(actorId)) || string.IsNullOrWhiteSpace(actorId))
{ {
throw Forbidden(); throw Forbidden(forbiddenMessage);
} }
} }
private static bool IsStaff(ClaimsPrincipal user) private static bool IsStaff(ClaimsPrincipal user)
=> StaffRoles.Any(user.IsInRole); => StaffRoles.Any(user.IsInRole);
private static WorkOrderBoardValidationException Forbidden() private static WorkOrderBoardValidationException Forbidden(string? message = null)
=> new( => new(
"Forbidden", "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( public async Task<IReadOnlyList<WorkOrderMediaFileDto>?> GetMediaAsync(
int workOrderId, int workOrderId,
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!await _detailData.ExistsAsync(workOrderId)) WorkOrderMediaAuthorization.EnsureCanRead(user, actorId);
if (!await _detailData.ExistsAsync(workOrderId, cancellationToken))
return null; return null;
var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId, cancellationToken);
if (workOrder == null) if (workOrder == null)
return 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); return WorkOrderMediaProjection.ProjectAll(workOrder, attachments);
} }

View file

@ -6,7 +6,11 @@ namespace SeaHaven.Services.Interfaces
{ {
public interface IWorkOrderMediaService 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> /// <summary>
/// Authorizes the caller and validates the work order is mutable before any blob storage write. /// 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(); await context.SaveChangesAsync();
var media = await service.GetMediaAsync(1); var media = await service.GetMediaAsync(1, AuthenticatedUser(), "actor-1");
Assert.NotNull(media); Assert.NotNull(media);
Assert.Equal(3, media!.Count); Assert.Equal(3, media!.Count);
@ -640,6 +640,97 @@ public class WorkOrderMediaServiceTests
Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Extra); 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] [Fact]
public async Task DeleteMedia_ReadOnlyWorkOrder_Throws() public async Task DeleteMedia_ReadOnlyWorkOrder_Throws()
{ {