From 5ced929f85bbfa925fcf7a6d0f9d9fab5f703497 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:59:54 +0100 Subject: [PATCH 01/18] test(capture): reproduce Viewer board-attachment authorization gap --- ...aptureBoardAttachmentAuthorizationTests.cs | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/CaptureBoardAttachmentAuthorizationTests.cs diff --git a/backend/tests/Taskdeck.Application.Tests/Services/CaptureBoardAttachmentAuthorizationTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/CaptureBoardAttachmentAuthorizationTests.cs new file mode 100644 index 000000000..254a1b0f8 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/CaptureBoardAttachmentAuthorizationTests.cs @@ -0,0 +1,133 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class CaptureBoardAttachmentAuthorizationTests +{ + private readonly Mock _unitOfWork = new(); + private readonly Mock _authorization = new(); + private readonly Mock _users = new(); + private readonly Mock _llmQueue = new(); + private readonly User _user = new("capture-viewer", "capture-viewer@example.com", "Password1!"); + + public CaptureBoardAttachmentAuthorizationTests() + { + _unitOfWork.SetupGet(unit => unit.Users).Returns(_users.Object); + _unitOfWork.SetupGet(unit => unit.LlmQueue).Returns(_llmQueue.Object); + _users + .Setup(repository => repository.GetByIdAsync( + _user.Id, + It.IsAny())) + .ReturnsAsync(_user); + _llmQueue + .Setup(repository => repository.AddAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync((LlmRequest request, CancellationToken _) => request); + _unitOfWork + .Setup(unit => unit.SaveChangesAsync(It.IsAny())) + .ReturnsAsync(1); + } + + private CaptureService BuildService() => new( + _unitOfWork.Object, + _authorization.Object); + + [Fact] + public async Task CreateAsync_RejectsReadableBoard_WhenCallerCannotWrite() + { + var boardId = Guid.NewGuid(); + _authorization + .Setup(service => service.CanReadBoardAsync(_user.Id, boardId)) + .ReturnsAsync(Result.Success(true)); + _authorization + .Setup(service => service.CanWriteBoardAsync(_user.Id, boardId)) + .ReturnsAsync(Result.Success(false)); + + var result = await BuildService().CreateAsync( + _user.Id, + new CreateCaptureItemDto(boardId, "Viewer must not attach this capture", "paste")); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + _authorization.Verify( + service => service.CanWriteBoardAsync(_user.Id, boardId), + Times.Once); + _llmQueue.Verify( + repository => repository.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + _unitOfWork.Verify( + unit => unit.SaveChangesAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task CreateAsync_PropagatesWriteAuthorizationFailure_WithoutPersistence() + { + var boardId = Guid.NewGuid(); + _authorization + .Setup(service => service.CanReadBoardAsync(_user.Id, boardId)) + .ReturnsAsync(Result.Success(true)); + _authorization + .Setup(service => service.CanWriteBoardAsync(_user.Id, boardId)) + .ReturnsAsync(Result.Failure( + ErrorCodes.NotFound, + "Board not found")); + + var result = await BuildService().CreateAsync( + _user.Id, + new CreateCaptureItemDto(boardId, "Missing board", "paste")); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + result.ErrorMessage.Should().Be("Board not found"); + _llmQueue.Verify( + repository => repository.AddAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + _unitOfWork.Verify( + unit => unit.SaveChangesAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task CreateAsync_AllowsWritableBoard_WithoutConsultingReadPermission() + { + var boardId = Guid.NewGuid(); + _authorization + .Setup(service => service.CanWriteBoardAsync(_user.Id, boardId)) + .ReturnsAsync(Result.Success(true)); + _authorization + .Setup(service => service.CanReadBoardAsync(_user.Id, boardId)) + .ThrowsAsync(new InvalidOperationException("Read permission is not the attachment contract")); + + var result = await BuildService().CreateAsync( + _user.Id, + new CreateCaptureItemDto(boardId, "Editor may attach this capture", "paste")); + + result.IsSuccess.Should().BeTrue(); + result.Value.BoardId.Should().Be(boardId); + _authorization.Verify( + service => service.CanReadBoardAsync(It.IsAny(), It.IsAny()), + Times.Never); + _llmQueue.Verify( + repository => repository.AddAsync( + It.Is(request => + request.UserId == _user.Id && request.BoardId == boardId), + It.IsAny()), + Times.Once); + _unitOfWork.Verify( + unit => unit.SaveChangesAsync(It.IsAny()), + Times.Once); + } +} From 770b4872619908784198542f995b289642d5e7f7 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:00:11 +0100 Subject: [PATCH 02/18] test(capture): add authenticated Viewer attachment negative --- ...ureBoardAttachmentAuthorizationApiTests.cs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 backend/tests/Taskdeck.Api.Tests/CaptureBoardAttachmentAuthorizationApiTests.cs diff --git a/backend/tests/Taskdeck.Api.Tests/CaptureBoardAttachmentAuthorizationApiTests.cs b/backend/tests/Taskdeck.Api.Tests/CaptureBoardAttachmentAuthorizationApiTests.cs new file mode 100644 index 000000000..9a8d299f5 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/CaptureBoardAttachmentAuthorizationApiTests.cs @@ -0,0 +1,97 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Taskdeck.Api.Tests.Support; +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Infrastructure.Persistence; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class CaptureBoardAttachmentAuthorizationApiTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + private readonly HttpClient _client; + + public CaptureBoardAttachmentAuthorizationApiTests(TestWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient(); + } + + [Fact] + public async Task Viewer_CannotCreateCaptureAttachedToReadableBoard() + { + var suffix = Guid.NewGuid().ToString("N"); + var owner = await ApiTestHarness.AuthenticateAsync(_client, $"capture-owner-{suffix}"); + var board = await ApiTestHarness.CreateBoardAsync( + _client, + $"Viewer capture boundary {suffix}"); + var viewer = await ApiTestHarness.AuthenticateAsync(_client, $"capture-viewer-{suffix}"); + + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.BoardAccesses.Add(new BoardAccess( + board.Id, + viewer.UserId, + UserRole.Viewer, + owner.UserId)); + await db.SaveChangesAsync(); + } + + var response = await _client.PostAsJsonAsync( + "/api/capture/items", + new CreateCaptureItemDto( + board.Id, + "A Viewer must not attach a capture to this board", + "paste")); + + await ApiTestHarness.AssertErrorContractAsync( + response, + HttpStatusCode.Forbidden, + "Forbidden"); + var captures = await _client.GetFromJsonAsync>( + "/api/capture/items"); + captures.Should().BeEmpty( + "a refused board attachment must not persist a board-scoped capture"); + } + + [Fact] + public async Task Editor_CanCreateCaptureAttachedToWritableBoard() + { + var suffix = Guid.NewGuid().ToString("N"); + var owner = await ApiTestHarness.AuthenticateAsync(_client, $"capture-owner-{suffix}"); + var board = await ApiTestHarness.CreateBoardAsync( + _client, + $"Editor capture boundary {suffix}"); + var editor = await ApiTestHarness.AuthenticateAsync(_client, $"capture-editor-{suffix}"); + + using (var scope = _factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.BoardAccesses.Add(new BoardAccess( + board.Id, + editor.UserId, + UserRole.Editor, + owner.UserId)); + await db.SaveChangesAsync(); + } + + var response = await _client.PostAsJsonAsync( + "/api/capture/items", + new CreateCaptureItemDto( + board.Id, + "An Editor may attach a capture to this board", + "paste")); + + response.StatusCode.Should().Be(HttpStatusCode.Created); + var capture = await response.Content.ReadFromJsonAsync(); + capture.Should().NotBeNull(); + capture!.BoardId.Should().Be(board.Id); + capture.UserId.Should().Be(editor.UserId); + } +} From ff77d8a0332fd273df4456e588bf03677f8e8135 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:08:37 +0100 Subject: [PATCH 03/18] fix(capture): require write access for board attachment --- .../src/Taskdeck.Application/Services/CaptureService.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/CaptureService.cs b/backend/src/Taskdeck.Application/Services/CaptureService.cs index 7128c2f64..dc8d6c5a4 100644 --- a/backend/src/Taskdeck.Application/Services/CaptureService.cs +++ b/backend/src/Taskdeck.Application/Services/CaptureService.cs @@ -191,12 +191,17 @@ public async Task> CreateAsync( if (dto.BoardId.HasValue) { - var permissionResult = await _authorizationService.CanReadBoardAsync(userId, dto.BoardId.Value); + // A board-scoped capture can enter that board's proposal queue. Keep the + // attachment boundary aligned with triage: readable Viewer access is not + // authority to inject work into a board only writers can modify (#3291). + var permissionResult = await _authorizationService.CanWriteBoardAsync(userId, dto.BoardId.Value); if (!permissionResult.IsSuccess) return Result.Failure(permissionResult.ErrorCode, permissionResult.ErrorMessage); if (!permissionResult.Value) - return Result.Failure(ErrorCodes.Forbidden, "You do not have access to this board"); + return Result.Failure( + ErrorCodes.Forbidden, + "You do not have permission to attach captures to this board"); } var sourceResult = ResolveSource(dto.Source); From 553ad7183149eeccbe8fec318d4224c1c3c88933 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:20:28 +0100 Subject: [PATCH 04/18] test(capture): import shared error codes --- .../Services/CaptureBoardAttachmentAuthorizationTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/tests/Taskdeck.Application.Tests/Services/CaptureBoardAttachmentAuthorizationTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/CaptureBoardAttachmentAuthorizationTests.cs index 254a1b0f8..10cc76aac 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/CaptureBoardAttachmentAuthorizationTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/CaptureBoardAttachmentAuthorizationTests.cs @@ -5,6 +5,7 @@ using Taskdeck.Application.Services; using Taskdeck.Domain.Common; using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; using Xunit; namespace Taskdeck.Application.Tests.Services; From cac5fe03b65fedae7c21eb93d9fd9e1c4d8a2c81 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:36:20 +0100 Subject: [PATCH 05/18] test(capture): align dual-write fixture with write authorization --- .../Services/CaptureServiceDualWriteTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/Taskdeck.Application.Tests/Services/CaptureServiceDualWriteTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/CaptureServiceDualWriteTests.cs index 589a64cf0..4e44b5266 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/CaptureServiceDualWriteTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/CaptureServiceDualWriteTests.cs @@ -176,7 +176,7 @@ public async Task CreateAsync_WithDualWriteEnabled_ShouldCarryTheBoardAsContextH var boardId = Guid.NewGuid(); Capture? mirrored = null; _authorizationServiceMock - .Setup(s => s.CanReadBoardAsync(_userId, boardId)) + .Setup(s => s.CanWriteBoardAsync(_userId, boardId)) .ReturnsAsync(Result.Success(true)); _captureStoreMock .Setup(s => s.AddAsync(It.IsAny(), It.IsAny())) From 2026199a20825e1d2c4615faf04175fb51ea13ed Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:36:38 +0100 Subject: [PATCH 06/18] test(capture): keep authorization usernames within contract --- .../CaptureBoardAttachmentAuthorizationApiTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/tests/Taskdeck.Api.Tests/CaptureBoardAttachmentAuthorizationApiTests.cs b/backend/tests/Taskdeck.Api.Tests/CaptureBoardAttachmentAuthorizationApiTests.cs index 9a8d299f5..45e90fc64 100644 --- a/backend/tests/Taskdeck.Api.Tests/CaptureBoardAttachmentAuthorizationApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/CaptureBoardAttachmentAuthorizationApiTests.cs @@ -25,12 +25,12 @@ public CaptureBoardAttachmentAuthorizationApiTests(TestWebApplicationFactory fac [Fact] public async Task Viewer_CannotCreateCaptureAttachedToReadableBoard() { - var suffix = Guid.NewGuid().ToString("N"); - var owner = await ApiTestHarness.AuthenticateAsync(_client, $"capture-owner-{suffix}"); + var suffix = Guid.NewGuid().ToString("N")[..8]; + var owner = await ApiTestHarness.AuthenticateAsync(_client, "cap-owner"); var board = await ApiTestHarness.CreateBoardAsync( _client, $"Viewer capture boundary {suffix}"); - var viewer = await ApiTestHarness.AuthenticateAsync(_client, $"capture-viewer-{suffix}"); + var viewer = await ApiTestHarness.AuthenticateAsync(_client, "cap-viewer"); using (var scope = _factory.Services.CreateScope()) { @@ -63,12 +63,12 @@ await ApiTestHarness.AssertErrorContractAsync( [Fact] public async Task Editor_CanCreateCaptureAttachedToWritableBoard() { - var suffix = Guid.NewGuid().ToString("N"); - var owner = await ApiTestHarness.AuthenticateAsync(_client, $"capture-owner-{suffix}"); + var suffix = Guid.NewGuid().ToString("N")[..8]; + var owner = await ApiTestHarness.AuthenticateAsync(_client, "cap-owner"); var board = await ApiTestHarness.CreateBoardAsync( _client, $"Editor capture boundary {suffix}"); - var editor = await ApiTestHarness.AuthenticateAsync(_client, $"capture-editor-{suffix}"); + var editor = await ApiTestHarness.AuthenticateAsync(_client, "cap-editor"); using (var scope = _factory.Services.CreateScope()) { From 3311e01b383b6f26079731917f127347ad685965 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 19:45:22 +0100 Subject: [PATCH 07/18] test(capture): align fixtures with write authorization --- backend/tests/Taskdeck.Api.Tests/CaptureApiTests.cs | 13 ++++++++++--- .../Services/CaptureServiceTests.cs | 6 +++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/backend/tests/Taskdeck.Api.Tests/CaptureApiTests.cs b/backend/tests/Taskdeck.Api.Tests/CaptureApiTests.cs index 284aaa6a4..126ed39f8 100644 --- a/backend/tests/Taskdeck.Api.Tests/CaptureApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/CaptureApiTests.cs @@ -888,8 +888,8 @@ public async Task Triage_ShouldSucceed_WhenTargetBoardMemberIsEditor() [Fact] public async Task Triage_ShouldReturnForbidden_WhenAlreadyLinkedBoardIsReadOnlyForCaller() { - // The gate has to sit on the effective board, not only on the triage body: a capture created - // with a readable board (create is read-gated) and accepted with no body is the same vector. + // Create while the caller is write-capable, then demote the membership before triage. The + // already-linked board gate must still reject the read-only caller with no target body. var ownerClient = _factory.CreateClient(); var viewerClient = _factory.CreateClient(); await ApiTestHarness.AuthenticateAsync(ownerClient, "capture-triage-linked-gate-owner"); @@ -898,8 +898,10 @@ public async Task Triage_ShouldReturnForbidden_WhenAlreadyLinkedBoardIsReadOnlyF var grantResponse = await ownerClient.PostAsJsonAsync( $"/api/boards/{board.Id}/access", - new GrantAccessDto(board.Id, viewer.UserId, UserRole.Viewer)); + new GrantAccessDto(board.Id, viewer.UserId, UserRole.Editor)); grantResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var access = await grantResponse.Content.ReadFromJsonAsync(); + access.Should().NotBeNull(); var createResponse = await viewerClient.PostAsJsonAsync( "/api/capture/items", @@ -909,6 +911,11 @@ public async Task Triage_ShouldReturnForbidden_WhenAlreadyLinkedBoardIsReadOnlyF created.Should().NotBeNull(); created!.BoardId.Should().Be(board.Id); + var demoteResponse = await ownerClient.PutAsJsonAsync( + $"/api/boards/{board.Id}/access/{access!.Id}", + new UpdateAccessDto(UserRole.Viewer)); + demoteResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var triageResponse = await viewerClient.PostAsync($"/api/capture/items/{created.Id}/triage", null); await ApiTestHarness.AssertErrorContractAsync(triageResponse, HttpStatusCode.Forbidden, "Forbidden"); diff --git a/backend/tests/Taskdeck.Application.Tests/Services/CaptureServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/CaptureServiceTests.cs index fddbdf5dd..088e70016 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/CaptureServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/CaptureServiceTests.cs @@ -125,7 +125,7 @@ public async Task CreateAsync_ShouldPersistCaptureRequestAndReturnDetail() .Setup(r => r.GetByIdAsync(userId, default)) .ReturnsAsync(user); _authorizationServiceMock - .Setup(s => s.CanReadBoardAsync(userId, boardId)) + .Setup(s => s.CanWriteBoardAsync(userId, boardId)) .ReturnsAsync(Result.Success(true)); _llmQueueRepositoryMock .Setup(r => r.AddAsync(It.IsAny(), default)) @@ -177,7 +177,7 @@ public async Task CreateAsync_ShouldAssignTranscriptRequestType_ForTranscriptSou .Setup(r => r.GetByIdAsync(userId, default)) .ReturnsAsync(user); _authorizationServiceMock - .Setup(s => s.CanReadBoardAsync(userId, boardId)) + .Setup(s => s.CanWriteBoardAsync(userId, boardId)) .ReturnsAsync(Result.Success(true)); _llmQueueRepositoryMock .Setup(r => r.AddAsync(It.IsAny(), default)) @@ -250,7 +250,7 @@ public async Task CreateAsync_ShouldReturnForbidden_WhenBoardAccessIsDenied() .Setup(r => r.GetByIdAsync(userId, default)) .ReturnsAsync(user); _authorizationServiceMock - .Setup(s => s.CanReadBoardAsync(userId, boardId)) + .Setup(s => s.CanWriteBoardAsync(userId, boardId)) .ReturnsAsync(Result.Success(false)); var result = await _service.CreateAsync(userId, dto); From a38a890b9a4dbf2f5c57244a6ff181eb27de65d1 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 22:13:39 +0100 Subject: [PATCH 08/18] fix(capture): serialize board authorization with enqueue --- .../Interfaces/IUnitOfWork.cs | 13 ++++++ .../Services/CaptureService.cs | 41 +++++++++++++++++++ .../Repositories/UnitOfWork.cs | 9 ++++ 3 files changed, 63 insertions(+) diff --git a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs index 9be0965e5..6db10d4b2 100644 --- a/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs +++ b/backend/src/Taskdeck.Application/Interfaces/IUnitOfWork.cs @@ -1,3 +1,5 @@ +using System.Data; + namespace Taskdeck.Application.Interfaces; public interface IUnitOfWork @@ -58,6 +60,17 @@ public interface IUnitOfWork Task BeginReadTransactionAsync(CancellationToken cancellationToken = default); Task BeginTransactionAsync(CancellationToken cancellationToken = default); + + /// + /// Begins a transaction at an explicit isolation level. The default implementation keeps + /// lightweight test doubles source-compatible; the production unit of work overrides it so + /// authorization reads that guard a write can share a serializable snapshot. + /// + Task BeginTransactionAsync( + IsolationLevel isolationLevel, + CancellationToken cancellationToken = default) + => BeginTransactionAsync(cancellationToken); + Task CommitTransactionAsync(CancellationToken cancellationToken = default); Task RollbackTransactionAsync(CancellationToken cancellationToken = default); } diff --git a/backend/src/Taskdeck.Application/Services/CaptureService.cs b/backend/src/Taskdeck.Application/Services/CaptureService.cs index dc8d6c5a4..58e3141d5 100644 --- a/backend/src/Taskdeck.Application/Services/CaptureService.cs +++ b/backend/src/Taskdeck.Application/Services/CaptureService.cs @@ -1,3 +1,4 @@ +using System.Data; using Microsoft.Extensions.Logging; using Taskdeck.Application.DTOs; using Taskdeck.Application.Interfaces; @@ -183,6 +184,17 @@ public async Task> CreateAsync( if (userId == Guid.Empty) return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty"); + var boardTransactionStarted = false; + + async Task RollbackBoardTransactionAsync() + { + if (!boardTransactionStarted) + return; + + await _unitOfWork.RollbackTransactionAsync(cancellationToken); + boardTransactionStarted = false; + } + try { var user = await _unitOfWork.Users.GetByIdAsync(userId, cancellationToken); @@ -191,22 +203,39 @@ public async Task> CreateAsync( if (dto.BoardId.HasValue) { + // The authorization read and queue insert must share one serializable snapshot. + // Otherwise a board owner can demote this caller after CanWriteBoardAsync returns + // but before SaveChangesAsync, admitting a capture under stale write authority. + await _unitOfWork.BeginTransactionAsync( + IsolationLevel.Serializable, + cancellationToken); + boardTransactionStarted = true; + // A board-scoped capture can enter that board's proposal queue. Keep the // attachment boundary aligned with triage: readable Viewer access is not // authority to inject work into a board only writers can modify (#3291). var permissionResult = await _authorizationService.CanWriteBoardAsync(userId, dto.BoardId.Value); if (!permissionResult.IsSuccess) + { + await RollbackBoardTransactionAsync(); return Result.Failure(permissionResult.ErrorCode, permissionResult.ErrorMessage); + } if (!permissionResult.Value) + { + await RollbackBoardTransactionAsync(); return Result.Failure( ErrorCodes.Forbidden, "You do not have permission to attach captures to this board"); + } } var sourceResult = ResolveSource(dto.Source); if (!sourceResult.IsSuccess) + { + await RollbackBoardTransactionAsync(); return Result.Failure(sourceResult.ErrorCode, sourceResult.ErrorMessage); + } var payload = new CapturePayloadV1( CaptureRequestContract.CurrentSchemaVersion, @@ -248,6 +277,12 @@ public async Task> CreateAsync( await _unitOfWork.SaveChangesAsync(cancellationToken); + if (boardTransactionStarted) + { + await _unitOfWork.CommitTransactionAsync(cancellationToken); + boardTransactionStarted = false; + } + return Result.Success(MapToDetailDto( request, attributedPayload, @@ -256,8 +291,14 @@ public async Task> CreateAsync( } catch (DomainException ex) { + await RollbackBoardTransactionAsync(); return Result.Failure(ex.ErrorCode, ex.Message); } + catch + { + await RollbackBoardTransactionAsync(); + throw; + } } public async Task>> ListAsync( diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs index 1955d6c38..d967e949e 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/UnitOfWork.cs @@ -262,6 +262,15 @@ public async Task BeginTransactionAsync(CancellationToken cancellationToken = de _transaction = await _context.Database.BeginTransactionAsync(cancellationToken); } + public async Task BeginTransactionAsync( + IsolationLevel isolationLevel, + CancellationToken cancellationToken = default) + { + _transaction = await _context.Database.BeginTransactionAsync( + isolationLevel, + cancellationToken); + } + public async Task CommitTransactionAsync(CancellationToken cancellationToken = default) { var transaction = _transaction; From 86eca5d8c3817577ef9b1adaf3d4d68932a86a6d Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 21 Sep 2026 22:13:48 +0100 Subject: [PATCH 09/18] fix(capture): hide board entry points for viewers --- .../src/components/board/BoardActionRail.vue | 9 ++++++++- .../src/components/board/BoardDialogHost.vue | 2 +- .../src/tests/components/BoardActionRail.spec.ts | 11 +++++++++-- .../src/tests/components/BoardDialogHost.spec.ts | 6 ++++++ .../taskdeck-web/src/tests/views/BoardView.spec.ts | 12 ++++++++++++ .../src/tests/views/paper/PaperBoardView.spec.ts | 8 ++++++++ frontend/taskdeck-web/src/views/BoardView.vue | 7 ++++++- .../taskdeck-web/src/views/paper/PaperBoardView.vue | 10 +++++++++- 8 files changed, 59 insertions(+), 6 deletions(-) diff --git a/frontend/taskdeck-web/src/components/board/BoardActionRail.vue b/frontend/taskdeck-web/src/components/board/BoardActionRail.vue index 60e37262d..19c45c7f8 100644 --- a/frontend/taskdeck-web/src/components/board/BoardActionRail.vue +++ b/frontend/taskdeck-web/src/components/board/BoardActionRail.vue @@ -1,4 +1,11 @@