mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 06:03:12 +00:00
fix(work-orders): enforce media auth and base scope on mutations
Require an authenticated ClaimsPrincipal at service entry and filter tracked work orders with board base scope so deleted/template rows surface as NotFound without disclosure.
This commit is contained in:
parent
2ec85d1193
commit
899da0eb4f
5 changed files with 117 additions and 25 deletions
|
|
@ -53,10 +53,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
{
|
||||
WorkOrderMediaFileRules.EnsureAllowed(file);
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
await _workOrderMediaService.EnsureCanMutateMediaAsync(id, actorId, cancellationToken);
|
||||
await _workOrderMediaService.EnsureCanMutateMediaAsync(id, User, actorId, cancellationToken);
|
||||
|
||||
fileUrl = await _fileStorage.SaveFileAsync(file);
|
||||
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId, cancellationToken);
|
||||
var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, User, actorId, cancellationToken);
|
||||
return Ok(media);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
|
|
@ -94,7 +94,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var media = await _workOrderMediaService.UpdateMediaCategoryAsync(
|
||||
id, mediaId, category, workOrderVersion, actorId, cancellationToken);
|
||||
id, mediaId, category, workOrderVersion, User, actorId, cancellationToken);
|
||||
return Ok(media);
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
|
|
@ -126,7 +126,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
|||
try
|
||||
{
|
||||
var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
await _workOrderMediaService.DeleteMediaAsync(id, mediaId, workOrderVersion, actorId, cancellationToken);
|
||||
await _workOrderMediaService.DeleteMediaAsync(id, mediaId, workOrderVersion, User, actorId, cancellationToken);
|
||||
return NoContent();
|
||||
}
|
||||
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ namespace SeaHaven.DataServices.Implementation
|
|||
}
|
||||
|
||||
public Task<WorkOrder?> GetTrackedWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||
=> _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId, cancellationToken);
|
||||
=> _context.workOrders.FirstOrDefaultAsync(
|
||||
w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null),
|
||||
cancellationToken);
|
||||
|
||||
public Task<WorkOrderAttachments?> GetTrackedAttachmentAsync(int mediaId, int workOrderId, CancellationToken cancellationToken)
|
||||
=> _context.workOrderAttachments.FirstOrDefaultAsync(
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.DataServices.Interfaces;
|
||||
|
|
@ -41,10 +42,11 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
public async Task EnsureCanMutateMediaAsync(
|
||||
int workOrderId,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureAuthenticatedActor(actorId);
|
||||
EnsureAuthenticatedCaller(user, actorId);
|
||||
await GetMutableWorkOrderAsync(workOrderId, cancellationToken);
|
||||
}
|
||||
|
||||
|
|
@ -52,10 +54,11 @@ namespace SeaHaven.Services.Implementation
|
|||
int workOrderId,
|
||||
WorkOrderMediaCategory? category,
|
||||
string fileUrl,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureAuthenticatedActor(actorId);
|
||||
EnsureAuthenticatedCaller(user, actorId);
|
||||
|
||||
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
|
||||
var workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken);
|
||||
|
|
@ -132,10 +135,11 @@ namespace SeaHaven.Services.Implementation
|
|||
int mediaId,
|
||||
WorkOrderMediaCategory category,
|
||||
string? workOrderVersion,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureAuthenticatedActor(actorId);
|
||||
EnsureAuthenticatedCaller(user, actorId);
|
||||
|
||||
if (mediaId <= 0)
|
||||
throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be categorized via this endpoint.");
|
||||
|
|
@ -209,10 +213,11 @@ namespace SeaHaven.Services.Implementation
|
|||
int workOrderId,
|
||||
int mediaId,
|
||||
string? workOrderVersion,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureAuthenticatedActor(actorId);
|
||||
EnsureAuthenticatedCaller(user, actorId);
|
||||
|
||||
if (mediaId <= 0)
|
||||
throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint.");
|
||||
|
|
@ -241,9 +246,7 @@ namespace SeaHaven.Services.Implementation
|
|||
|
||||
private async Task<WorkOrder> GetMutableWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _detailData.ExistsAsync(workOrderId))
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
|
||||
// Base-scoped lookup: deleted/template work orders surface as NotFound (no disclosure).
|
||||
var workOrder = await _mediaData.GetTrackedWorkOrderAsync(workOrderId, cancellationToken);
|
||||
if (workOrder == null)
|
||||
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
|
||||
|
|
@ -266,10 +269,16 @@ namespace SeaHaven.Services.Implementation
|
|||
_mediaData.SetExpectedWorkOrderVersion(workOrder, expected);
|
||||
}
|
||||
|
||||
private static void EnsureAuthenticatedActor(string? actorId)
|
||||
private static void EnsureAuthenticatedCaller(ClaimsPrincipal user, string? actorId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(actorId))
|
||||
throw new WorkOrderBoardValidationException("Forbidden", "You are not allowed to mutate work order media.");
|
||||
if (user is null
|
||||
|| !(user.Identity?.IsAuthenticated ?? false)
|
||||
|| string.IsNullOrWhiteSpace(actorId))
|
||||
{
|
||||
throw new WorkOrderBoardValidationException(
|
||||
"Forbidden",
|
||||
"You are not allowed to mutate work order media.");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? ParseRowVersion(string? base64)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Claims;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
using SeaHaven.Services.DTOs;
|
||||
|
||||
|
|
@ -10,12 +11,17 @@ namespace SeaHaven.Services.Interfaces
|
|||
/// <summary>
|
||||
/// Authorizes the caller and validates the work order is mutable before any blob storage write.
|
||||
/// </summary>
|
||||
Task EnsureCanMutateMediaAsync(int workOrderId, string? actorId, CancellationToken cancellationToken = default);
|
||||
Task EnsureCanMutateMediaAsync(
|
||||
int workOrderId,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<WorkOrderMediaFileDto> AddMediaAsync(
|
||||
int workOrderId,
|
||||
WorkOrderMediaCategory? category,
|
||||
string fileUrl,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
|
|
@ -24,6 +30,7 @@ namespace SeaHaven.Services.Interfaces
|
|||
int mediaId,
|
||||
WorkOrderMediaCategory category,
|
||||
string? workOrderVersion,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
|
|
@ -31,6 +38,7 @@ namespace SeaHaven.Services.Interfaces
|
|||
int workOrderId,
|
||||
int mediaId,
|
||||
string? workOrderVersion,
|
||||
ClaimsPrincipal user,
|
||||
string? actorId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Data.SeaHavenIndustries;
|
||||
using Data.SeaHavenIndustries.Enums;
|
||||
|
|
@ -589,6 +590,17 @@ public class WorkOrderMediaServiceTests
|
|||
private static string ToVersion(WorkOrder workOrder)
|
||||
=> Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
|
||||
|
||||
private static ClaimsPrincipal AuthenticatedUser(string actorId = "actor-1")
|
||||
{
|
||||
var identity = new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.NameIdentifier, actorId) },
|
||||
authenticationType: "Test");
|
||||
return new ClaimsPrincipal(identity);
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal UnauthenticatedUser()
|
||||
=> new ClaimsPrincipal(new ClaimsIdentity());
|
||||
|
||||
[Fact]
|
||||
public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra()
|
||||
{
|
||||
|
|
@ -636,7 +648,7 @@ public class WorkOrderMediaServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.DeleteMediaAsync(1, 10, ToVersion(context.workOrders.Single()), "actor-1"));
|
||||
service.DeleteMediaAsync(1, 10, ToVersion(context.workOrders.Single()), AuthenticatedUser(), "actor-1"));
|
||||
|
||||
Assert.Equal("ReadOnly", ex.Code);
|
||||
}
|
||||
|
|
@ -654,7 +666,7 @@ public class WorkOrderMediaServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", "actor-1"));
|
||||
service.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", AuthenticatedUser(), "actor-1"));
|
||||
|
||||
Assert.Equal("UseCompletionDocEndpoint", ex.Code);
|
||||
}
|
||||
|
|
@ -671,7 +683,7 @@ public class WorkOrderMediaServiceTests
|
|||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var media = await service.AddMediaAsync(1, null, "https://example.com/photo.jpg", "actor-1");
|
||||
var media = await service.AddMediaAsync(1, null, "https://example.com/photo.jpg", AuthenticatedUser(), "actor-1");
|
||||
|
||||
Assert.True(media.Id > 0);
|
||||
Assert.Equal(WorkOrderMediaCategory.Extra, media.Category);
|
||||
|
|
@ -692,7 +704,30 @@ public class WorkOrderMediaServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.AddMediaAsync(1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", null));
|
||||
service.AddMediaAsync(1, WorkOrderMediaCategory.Extra, "https://example.com/photo.jpg", AuthenticatedUser(), null));
|
||||
|
||||
Assert.Equal("Forbidden", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMedia_UnauthenticatedCaller_ThrowsForbidden()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.AddMediaAsync(
|
||||
1,
|
||||
WorkOrderMediaCategory.Extra,
|
||||
"https://example.com/photo.jpg",
|
||||
UnauthenticatedUser(),
|
||||
"actor-1"));
|
||||
|
||||
Assert.Equal("Forbidden", ex.Code);
|
||||
}
|
||||
|
|
@ -702,7 +737,45 @@ public class WorkOrderMediaServiceTests
|
|||
{
|
||||
var (_, service) = CreateSut();
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.EnsureCanMutateMediaAsync(99, "actor-1"));
|
||||
service.EnsureCanMutateMediaAsync(99, AuthenticatedUser(), "actor-1"));
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureCanMutateMedia_DeletedWorkOrder_ThrowsNotFound()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
IsDeleted = true,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1"));
|
||||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureCanMutateMedia_TemplateWorkOrder_ThrowsNotFound()
|
||||
{
|
||||
var (context, service) = CreateSut();
|
||||
context.workOrders.Add(new WorkOrder
|
||||
{
|
||||
Id = 1,
|
||||
LifecycleStatus = LifecycleStatus.Scheduled,
|
||||
istemplate = true,
|
||||
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.EnsureCanMutateMediaAsync(1, AuthenticatedUser(), "actor-1"));
|
||||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
}
|
||||
|
||||
|
|
@ -727,7 +800,7 @@ public class WorkOrderMediaServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var media = await service.UpdateMediaCategoryAsync(
|
||||
1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), "actor-1");
|
||||
1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), AuthenticatedUser(), "actor-1");
|
||||
|
||||
Assert.Equal(-1, media.Id);
|
||||
Assert.Equal(WorkOrderMediaCategory.Before, media.Category);
|
||||
|
|
@ -758,7 +831,7 @@ public class WorkOrderMediaServiceTests
|
|||
|
||||
var stale = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 });
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, stale, "actor-1"));
|
||||
service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, stale, AuthenticatedUser(), "actor-1"));
|
||||
|
||||
Assert.Equal("ConcurrencyConflict", ex.Code);
|
||||
}
|
||||
|
|
@ -790,7 +863,7 @@ public class WorkOrderMediaServiceTests
|
|||
|
||||
var version = ToVersion(context.workOrders.Single(w => w.Id == 1));
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, version, "actor-1"));
|
||||
service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Extra, version, AuthenticatedUser(), "actor-1"));
|
||||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
}
|
||||
|
|
@ -817,7 +890,7 @@ public class WorkOrderMediaServiceTests
|
|||
await context.SaveChangesAsync();
|
||||
|
||||
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
|
||||
service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), "actor-1"));
|
||||
service.UpdateMediaCategoryAsync(1, 10, WorkOrderMediaCategory.Before, ToVersion(wo), AuthenticatedUser(), "actor-1"));
|
||||
|
||||
Assert.Equal("NotFound", ex.Code);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue