fix(work-orders): scope legacy GET GetComments by account [SH-221]

Pass ClaimsPrincipal into GetCommentsAsync and filter via
GetAllForAccountAsync so account-scoped callers cannot enumerate
cross-tenant comments. ADR + cross-account tests updated.
This commit is contained in:
Arthur Bassi 2026-08-11 15:18:29 -03:00
parent 3c090e2757
commit de0f6da8fb
7 changed files with 80 additions and 6 deletions

View file

@ -370,8 +370,15 @@ namespace Api.SeaHavenIndustries.Controllers
[Route("GetComments")]
public async Task<IActionResult> GetComments()
{
var data = await _workOrderService.GetCommentsAsync();
return Ok(data);
try
{
var data = await _workOrderService.GetCommentsAsync(User);
return Ok(data);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = ex.Message });
}
}
[HttpGet]

View file

@ -1,5 +1,6 @@
using Data.SeaHavenIndustries;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -25,6 +26,20 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync();
}
public async Task<IEnumerable<Comments>> GetAllForAccountAsync(int? accountId)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId.HasValue)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, accountId.Value);
return await (
from c in _context.Comments.AsNoTracking().Include(c => c.ApplicationUser)
join w in workOrders on c.WorkerOrderId equals w.Id
orderby c.CreatedDate
select c
).ToListAsync();
}
public async Task<IEnumerable<Comments>> GetByWorkOrderIdAsync(int workOrderId)
{
return await _context.Comments

View file

@ -6,6 +6,11 @@ namespace SeaHaven.DataServices.Interfaces
{
Task<Comments?> GetByIdAsync(int id);
Task<IEnumerable<Comments>> GetAllAsync();
/// <summary>
/// Comments linked to non-deleted, non-template work orders.
/// When <paramref name="accountId"/> is set, only that account's WOs; null = org-wide.
/// </summary>
Task<IEnumerable<Comments>> GetAllForAccountAsync(int? accountId);
Task<IEnumerable<Comments>> GetByWorkOrderIdAsync(int workOrderId);
Task<IEnumerable<Comments>> GetByDispatchIdAsync(int dispatchId);
Task<Comments> AddAsync(Comments comment);

View file

@ -486,9 +486,10 @@ namespace SeaHaven.Services.Implementation
};
}
public async Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync()
public async Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync(ClaimsPrincipal user)
{
var comments = await _commentDataService.GetAllAsync();
var accountId = _accountResolver.ResolveAccountFilter(user);
var comments = await _commentDataService.GetAllForAccountAsync(accountId);
return comments.Select(s => new CommentListItemReadModel
{
Id = s.Id,

View file

@ -39,7 +39,7 @@ namespace SeaHaven.Services.Interfaces
Task<bool> DeleteWorkOrderCascadeAsync(int id, string userId);
Task<Comments> AddCommentAsync(AddCommentInput input, ClaimsPrincipal user, string userId);
Task<CommentResult> AddCommentJsonAsync(AddCommentInput input, ClaimsPrincipal user, string userId);
Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync();
Task<IEnumerable<CommentListItemReadModel>> GetCommentsAsync(ClaimsPrincipal user);
Task<IEnumerable<CommentListItemReadModel>?> GetCommentsByWorkorderIdAsync(int woid, ClaimsPrincipal user);
Task<IEnumerable<WorkOrder>> GetWorkordersDDAsync(int? accountId = null);
Task<IEnumerable<WorkorderFilterVM>> GetWorkordersAsync(int? accountId = null);

View file

@ -636,4 +636,50 @@ public class WorkOrderAccountScopeTests
Assert.Equal(DocStatus.Yes, result.DocStatus);
Assert.Equal("https://example.com/ok.pdf", result.SignOffAttachment);
}
[Fact]
public async Task LegacyGetComments_ScopedUser_HidesOtherAccountComments()
{
await using var context = CreateContext();
await SeedThreeAccountRowsAsync(context);
context.Comments.AddRange(
new Comments
{
WorkerOrderId = 1,
Commenttext = "Account A note",
Documents = "a.pdf",
CreatedDate = DateTime.UtcNow
},
new Comments
{
WorkerOrderId = 2,
Commenttext = "Account B secret",
Documents = "b.pdf",
CreatedDate = DateTime.UtcNow
},
new Comments
{
WorkerOrderId = 3,
Commenttext = "Null account note",
CreatedDate = DateTime.UtcNow
});
await context.SaveChangesAsync();
var service = CreateLegacyReadService(context);
var scoped = await service.GetCommentsAsync(
WorkOrderAccountTestHelpers.AccountUser("disp-1", 1, "Dispatcher"));
Assert.Single(scoped);
Assert.Equal("Account A note", scoped.Single().Commenttext);
Assert.DoesNotContain(scoped, c => c.Commenttext == "Account B secret");
Assert.DoesNotContain(scoped, c => c.Commenttext == "Null account note");
var orgWide = await service.GetCommentsAsync(WorkOrderAccountTestHelpers.OrgWideAdmin());
Assert.Equal(3, orgWide.Count());
var missingEx = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetCommentsAsync(WorkOrderAccountTestHelpers.MissingScope()));
Assert.Equal("Forbidden", missingEx.Code);
}
}

View file

@ -8,7 +8,7 @@
- JWT `account_id` when `ApplicationUser.AccountId` is set
- JWT `org_scope=all` when Admin has no AccountId (explicit signed elevation)
- **Reads** (board, list, advanced search, detail, media, board comments,
legacy comments-by-work-order-id): `ApplyBaseScope` +
legacy comments-by-work-order-id, legacy `GET GetComments`): `ApplyBaseScope` +
`ApplyAccountScope(int)` when account-scoped; org-wide path skips account filter
- **Writes** (board mutations, media, board/legacy comments, `POST …/completion-doc`):
same account filter at service/data entry; authorize before storing blobs