fix(work-orders): enforce media role scope and relational concurrency

Derive staff vs technician scope from claims (Assigned for User), map
DbUpdateConcurrencyException to a stable 409, and add SQLite competing-write
tests for categorize-vs-categorize and categorize-vs-delete.
This commit is contained in:
Arthur Bassi 2026-08-04 11:08:17 -03:00
parent 899da0eb4f
commit 680012d88b
4 changed files with 485 additions and 28 deletions

View file

@ -0,0 +1,82 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using SeaHaven.Services.Exceptions;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// Claims-derived authorization for work-order media mutations.
/// True multi-tenant CustomerId/TenantId is not modeled on WorkOrder/JWT;
/// scope is role + Assigned (<see cref="WorkOrder.AssignTo"/>) for Technician.
/// </summary>
public static class WorkOrderMediaAuthorization
{
private static readonly string[] StaffRoles =
{
"Admin",
"Manager",
"Dispatcher",
"Supervisor"
};
public static void EnsureCanMutate(ClaimsPrincipal user, string? actorId)
{
EnsureAuthenticated(user, actorId);
if (IsStaff(user) || user.IsInRole("User"))
return;
throw Forbidden();
}
public static void EnsureCanDelete(ClaimsPrincipal user, string? actorId)
{
EnsureAuthenticated(user, actorId);
// Technician (User) may upload/categorize assigned media but not delete.
if (IsStaff(user))
return;
throw Forbidden();
}
/// <summary>
/// Staff: any in-scope (non-deleted/non-template) work order.
/// Technician: only work orders assigned to the caller. Out-of-scope → NotFound (no disclosure).
/// </summary>
public static void EnsureWorkOrderInCallerScope(
ClaimsPrincipal user,
string actorId,
WorkOrder workOrder)
{
if (IsStaff(user))
return;
if (user.IsInRole("User")
&& string.Equals(workOrder.AssignTo, actorId, StringComparison.Ordinal))
{
return;
}
throw new WorkOrderBoardValidationException("NotFound", "Work order not found.");
}
private static void EnsureAuthenticated(ClaimsPrincipal user, string? actorId)
{
if (user is null
|| !(user.Identity?.IsAuthenticated ?? false)
|| string.IsNullOrWhiteSpace(actorId))
{
throw Forbidden();
}
}
private static bool IsStaff(ClaimsPrincipal user)
=> StaffRoles.Any(user.IsInRole);
private static WorkOrderBoardValidationException Forbidden()
=> new(
"Forbidden",
"You are not allowed to mutate work order media.");
}
}

View file

@ -1,6 +1,7 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
@ -46,8 +47,8 @@ namespace SeaHaven.Services.Implementation
string? actorId,
CancellationToken cancellationToken = default)
{
EnsureAuthenticatedCaller(user, actorId);
await GetMutableWorkOrderAsync(workOrderId, cancellationToken);
WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId);
await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken);
}
public async Task<WorkOrderMediaFileDto> AddMediaAsync(
@ -58,10 +59,10 @@ namespace SeaHaven.Services.Implementation
string? actorId,
CancellationToken cancellationToken = default)
{
EnsureAuthenticatedCaller(user, actorId);
WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId);
var resolvedCategory = category ?? WorkOrderMediaCategory.Extra;
var workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken);
var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken);
if (resolvedCategory == WorkOrderMediaCategory.Completion)
{
@ -76,7 +77,7 @@ namespace SeaHaven.Services.Implementation
workOrder.BeforPhotoAttachment = fileUrl;
await _auditService.StageFieldChangedAsync(
workOrderId, "BeforPhotoAttachment", oldBefore, fileUrl, actorId);
await _mediaData.SaveAsync(cancellationToken);
await SaveMediaAsync(cancellationToken);
return new WorkOrderMediaFileDto
{
Id = -1,
@ -92,7 +93,7 @@ namespace SeaHaven.Services.Implementation
workOrder.AfterPhotoAttachment = fileUrl;
await _auditService.StageFieldChangedAsync(
workOrderId, "AfterPhotoAttachment", oldAfter, fileUrl, actorId);
await _mediaData.SaveAsync(cancellationToken);
await SaveMediaAsync(cancellationToken);
return new WorkOrderMediaFileDto
{
Id = -2,
@ -118,7 +119,7 @@ namespace SeaHaven.Services.Implementation
null,
FormatMediaAuditValue(null, (attachment.Category ?? WorkOrderMediaCategory.Extra).ToString()),
actorId);
await _mediaData.SaveAsync(cancellationToken);
await SaveMediaAsync(cancellationToken);
return new WorkOrderMediaFileDto
{
@ -139,7 +140,7 @@ namespace SeaHaven.Services.Implementation
string? actorId,
CancellationToken cancellationToken = default)
{
EnsureAuthenticatedCaller(user, actorId);
WorkOrderMediaAuthorization.EnsureCanMutate(user, actorId);
if (mediaId <= 0)
throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be categorized via this endpoint.");
@ -151,7 +152,7 @@ namespace SeaHaven.Services.Implementation
"Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc.");
}
var workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken);
var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken);
ApplyExpectedVersion(workOrder, workOrderVersion);
var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken);
@ -178,7 +179,7 @@ namespace SeaHaven.Services.Implementation
FormatMediaAuditValue(mediaId, priorCategory),
FormatMediaAuditValue(mediaId, category.ToString()),
actorId);
await _mediaData.SaveAsync(cancellationToken);
await SaveMediaAsync(cancellationToken);
return new WorkOrderMediaFileDto
{
@ -197,7 +198,7 @@ namespace SeaHaven.Services.Implementation
FormatMediaAuditValue(mediaId, priorCategory),
FormatMediaAuditValue(mediaId, category.ToString()),
actorId);
await _mediaData.SaveAsync(cancellationToken);
await SaveMediaAsync(cancellationToken);
return new WorkOrderMediaFileDto
{
@ -217,12 +218,12 @@ namespace SeaHaven.Services.Implementation
string? actorId,
CancellationToken cancellationToken = default)
{
EnsureAuthenticatedCaller(user, actorId);
WorkOrderMediaAuthorization.EnsureCanDelete(user, actorId);
if (mediaId <= 0)
throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint.");
var workOrder = await GetMutableWorkOrderAsync(workOrderId, cancellationToken);
var workOrder = await GetMutableWorkOrderAsync(workOrderId, user, actorId!, cancellationToken);
ApplyExpectedVersion(workOrder, workOrderVersion);
var attachment = await _mediaData.GetTrackedAttachmentAsync(mediaId, workOrderId, cancellationToken);
@ -241,22 +242,42 @@ namespace SeaHaven.Services.Implementation
FormatMediaAuditValue(mediaId, priorCategory),
FormatMediaAuditValue(mediaId, "Deleted"),
actorId);
await _mediaData.SaveAsync(cancellationToken);
await SaveMediaAsync(cancellationToken);
}
private async Task<WorkOrder> GetMutableWorkOrderAsync(int workOrderId, CancellationToken cancellationToken)
private async Task<WorkOrder> GetMutableWorkOrderAsync(
int workOrderId,
ClaimsPrincipal user,
string actorId,
CancellationToken cancellationToken)
{
// 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.");
WorkOrderMediaAuthorization.EnsureWorkOrderInCallerScope(user, actorId, workOrder);
if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus))
throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status.");
return workOrder;
}
private async Task SaveMediaAsync(CancellationToken cancellationToken)
{
try
{
await _mediaData.SaveAsync(cancellationToken);
}
catch (DbUpdateConcurrencyException)
{
throw new WorkOrderBoardValidationException(
"ConcurrencyConflict",
"Work order was modified. Refresh and retry.");
}
}
private void ApplyExpectedVersion(WorkOrder workOrder, string? workOrderVersion)
{
var expected = ParseRowVersion(workOrderVersion);
@ -269,18 +290,6 @@ namespace SeaHaven.Services.Implementation
_mediaData.SetExpectedWorkOrderVersion(workOrder, expected);
}
private static void EnsureAuthenticatedCaller(ClaimsPrincipal user, string? actorId)
{
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)
{
if (string.IsNullOrWhiteSpace(base64))

View file

@ -0,0 +1,252 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Implementation;
namespace SeaHavenIndustries.Tests;
/// <summary>
/// Provider-backed media concurrency: two independently tracked contexts share one SQLite
/// connection so EF concurrency tokens and competing writes are exercised (not InMemory).
/// </summary>
public class WorkOrderMediaConcurrencyRelationalTests
{
[Fact]
public async Task CategorizeVsCategorize_SecondWriter_GetsConcurrencyConflict_WithoutPartialAudit()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = CreateOptions(connection);
await using (var seed = new SqliteMediaTestDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
seed.Users.AddRange(
new ApplicationUser
{
Id = "actor-a",
UserName = "actor-a",
NormalizedUserName = "ACTOR-A",
Email = "a@test.local",
NormalizedEmail = "A@TEST.LOCAL"
},
new ApplicationUser
{
Id = "actor-b",
UserName = "actor-b",
NormalizedUserName = "ACTOR-B",
Email = "b@test.local",
NormalizedEmail = "B@TEST.LOCAL"
});
seed.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
seed.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/photo.jpg",
Category = WorkOrderMediaCategory.Extra
});
await seed.SaveChangesAsync();
}
await using var contextA = new SqliteMediaTestDbContext(options);
await using var contextB = new SqliteMediaTestDbContext(options);
var serviceA = CreateService(contextA);
var serviceB = CreateService(contextB);
var versionA = await LoadVersionAsync(contextA, workOrderId: 1);
var versionB = await LoadVersionAsync(contextB, workOrderId: 1);
Assert.Equal(versionA, versionB);
var winner = await serviceA.UpdateMediaCategoryAsync(
1, 10, WorkOrderMediaCategory.Before, versionA, Admin("actor-a"), "actor-a");
Assert.Equal(WorkOrderMediaCategory.Before, winner.Category);
var loser = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
serviceB.UpdateMediaCategoryAsync(
1, 10, WorkOrderMediaCategory.After, versionB, Admin("actor-b"), "actor-b"));
Assert.Equal("ConcurrencyConflict", loser.Code);
await using var verify = new SqliteMediaTestDbContext(options);
var wo = await verify.workOrders.AsNoTracking().SingleAsync(w => w.Id == 1);
var attachment = await verify.workOrderAttachments.AsNoTracking().SingleAsync(a => a.Id == 10);
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
Assert.Equal("https://example.com/photo.jpg", wo.BeforPhotoAttachment);
Assert.True(string.IsNullOrEmpty(wo.AfterPhotoAttachment));
Assert.True(attachment.IsDeleted);
Assert.Single(audits);
Assert.Contains(audits, a => a.NewValue == "10:Before");
Assert.DoesNotContain(audits, a => a.NewValue == "10:After");
}
[Fact]
public async Task CategorizeVsDelete_SecondWriter_GetsConcurrencyConflict_WithoutPartialState()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
var options = CreateOptions(connection);
await using (var seed = new SqliteMediaTestDbContext(options))
{
await seed.Database.EnsureCreatedAsync();
seed.Users.AddRange(
new ApplicationUser
{
Id = "actor-a",
UserName = "actor-a",
NormalizedUserName = "ACTOR-A",
Email = "a@test.local",
NormalizedEmail = "A@TEST.LOCAL"
},
new ApplicationUser
{
Id = "actor-b",
UserName = "actor-b",
NormalizedUserName = "ACTOR-B",
Email = "b@test.local",
NormalizedEmail = "B@TEST.LOCAL"
});
seed.workOrders.Add(new WorkOrder
{
Id = 1,
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
seed.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/photo.jpg",
Category = WorkOrderMediaCategory.Extra
});
await seed.SaveChangesAsync();
}
await using var categorizeContext = new SqliteMediaTestDbContext(options);
await using var deleteContext = new SqliteMediaTestDbContext(options);
var categorizeService = CreateService(categorizeContext);
var deleteService = CreateService(deleteContext);
var categorizeVersion = await LoadVersionAsync(categorizeContext, workOrderId: 1);
var deleteVersion = await LoadVersionAsync(deleteContext, workOrderId: 1);
await categorizeService.UpdateMediaCategoryAsync(
1, 10, WorkOrderMediaCategory.Before, categorizeVersion, Admin("actor-a"), "actor-a");
var loser = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
deleteService.DeleteMediaAsync(
1, 10, deleteVersion, Admin("actor-b"), "actor-b"));
Assert.Equal("ConcurrencyConflict", loser.Code);
await using var verify = new SqliteMediaTestDbContext(options);
var wo = await verify.workOrders.AsNoTracking().SingleAsync(w => w.Id == 1);
var attachment = await verify.workOrderAttachments.AsNoTracking().SingleAsync(a => a.Id == 10);
var audits = await verify.WorkOrderAuditLogs.AsNoTracking().ToListAsync();
Assert.Equal("https://example.com/photo.jpg", wo.BeforPhotoAttachment);
Assert.True(attachment.IsDeleted);
Assert.Single(audits);
Assert.Contains(audits, a => a.NewValue == "10:Before");
Assert.DoesNotContain(audits, a => a.NewValue != null && a.NewValue.EndsWith(":Deleted", StringComparison.Ordinal));
}
private static DbContextOptions<ApplicationDbContext> CreateOptions(SqliteConnection connection)
=> new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
private static WorkOrderMediaService CreateService(ApplicationDbContext context)
{
var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
return new WorkOrderMediaService(
new WorkOrderMediaDataService(context),
new WorkOrderDetailDataService(context),
audit);
}
private static async Task<string> LoadVersionAsync(ApplicationDbContext context, int workOrderId)
{
var wo = await context.workOrders.AsNoTracking().SingleAsync(w => w.Id == workOrderId);
return Convert.ToBase64String(wo.RowVersion!);
}
private static ClaimsPrincipal Admin(string actorId)
=> new(new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, actorId),
new Claim(ClaimTypes.Role, "Admin")
},
"Test"));
private sealed class SqliteMediaTestDbContext : ApplicationDbContext
{
public SqliteMediaTestDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
{
if (index.GetFilter() != null)
index.SetFilter(null);
}
// SQLite has no rowversion type; keep a byte[] concurrency token and bump on save.
var property = builder.Entity<WorkOrder>().Property(w => w.RowVersion).Metadata;
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
property.IsConcurrencyToken = true;
}
public override int SaveChanges()
{
BumpWorkOrderRowVersions();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
BumpWorkOrderRowVersions();
return base.SaveChangesAsync(cancellationToken);
}
private void BumpWorkOrderRowVersions()
{
foreach (var entry in ChangeTracker.Entries<WorkOrder>())
{
if (entry.State != EntityState.Modified)
continue;
var current = entry.Entity.RowVersion != null && entry.Entity.RowVersion.Length > 0
? (byte[])entry.Entity.RowVersion.Clone()
: new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 };
for (var i = 0; i < current.Length; i++)
{
if (++current[i] != 0)
break;
}
entry.Entity.RowVersion = current;
}
}
}
}

View file

@ -590,7 +590,19 @@ 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")
private static ClaimsPrincipal AuthenticatedUser(string actorId = "actor-1", string role = "Admin")
{
var identity = new ClaimsIdentity(
new[]
{
new Claim(ClaimTypes.NameIdentifier, actorId),
new Claim(ClaimTypes.Role, role)
},
authenticationType: "Test");
return new ClaimsPrincipal(identity);
}
private static ClaimsPrincipal AuthenticatedWithoutRole(string actorId = "actor-1")
{
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, actorId) },
@ -732,6 +744,108 @@ public class WorkOrderMediaServiceTests
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task AddMedia_CallerWithoutRole_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",
AuthenticatedWithoutRole(),
"actor-1"));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task AddMedia_TechnicianOnUnassignedWorkOrder_ThrowsNotFound()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AssignTo = "other-tech",
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",
AuthenticatedUser("tech-1", "User"),
"tech-1"));
Assert.Equal("NotFound", ex.Code);
}
[Fact]
public async Task AddMedia_TechnicianOnAssignedWorkOrder_Succeeds()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AssignTo = "tech-1",
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
await context.SaveChangesAsync();
var media = await service.AddMediaAsync(
1,
null,
"https://example.com/photo.jpg",
AuthenticatedUser("tech-1", "User"),
"tech-1");
Assert.True(media.Id > 0);
Assert.Equal(WorkOrderMediaCategory.Extra, media.Category);
}
[Fact]
public async Task DeleteMedia_Technician_ThrowsForbidden()
{
var (context, service) = CreateSut();
context.workOrders.Add(new WorkOrder
{
Id = 1,
AssignTo = "tech-1",
LifecycleStatus = LifecycleStatus.Scheduled,
RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }
});
context.workOrderAttachments.Add(new WorkOrderAttachments
{
Id = 10,
WorkorderId = 1,
Attachments = "https://example.com/extra.pdf",
Category = WorkOrderMediaCategory.Extra
});
await context.SaveChangesAsync();
var ex = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.DeleteMediaAsync(
1,
10,
ToVersion(context.workOrders.Single()),
AuthenticatedUser("tech-1", "User"),
"tech-1"));
Assert.Equal("Forbidden", ex.Code);
}
[Fact]
public async Task EnsureCanMutateMedia_MissingWorkOrder_ThrowsNotFound()
{